ai-worklens-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { normalizeToolId } from "../../collector-protocol/src/tool-profiles.mjs";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const projectRoot = path.resolve(__dirname, "../../..");
10
+
11
+ function parseArgs(argv) {
12
+ const result = {};
13
+ for (let index = 0; index < argv.length; index += 1) {
14
+ const arg = argv[index];
15
+ if (!arg.startsWith("--")) continue;
16
+ const key = arg.slice(2);
17
+ const next = argv[index + 1];
18
+ if (!next || next.startsWith("--")) result[key] = true;
19
+ else {
20
+ result[key] = next;
21
+ index += 1;
22
+ }
23
+ }
24
+ return result;
25
+ }
26
+
27
+ function normalizeEmployee(employee) {
28
+ const employeeId = String(employee.employeeId || employee.id || "").trim();
29
+ return {
30
+ employeeId,
31
+ name: String(employee.name || employee.employeeName || employeeId).trim(),
32
+ pinyinName: String(employee.pinyinName || employee.employeePinyin || employee.registrationName || "").trim(),
33
+ department: String(employee.department || "").trim(),
34
+ role: String(employee.role || "").trim()
35
+ };
36
+ }
37
+
38
+ function readEmployees(filePath) {
39
+ if (!filePath) return [];
40
+ const raw = fs.readFileSync(filePath, "utf8");
41
+ const parsed = JSON.parse(raw);
42
+ return (Array.isArray(parsed) ? parsed : parsed.items || []).map(normalizeEmployee).filter((item) => item.employeeId);
43
+ }
44
+
45
+ export function buildTeamRolloutManifest(options = {}) {
46
+ const tool = normalizeToolId(options.tool || "codex");
47
+ const targetDir = options.targetDir || path.join(process.cwd(), "data/team-rollout", tool);
48
+ const employees = (options.employees || readEmployees(options.employeesFile)).map(normalizeEmployee).filter((item) => item.employeeId);
49
+ return {
50
+ version: 1,
51
+ generatedAt: new Date().toISOString(),
52
+ serverUrl: options.serverUrl || "http://127.0.0.1:8797",
53
+ collectorToken: options.collectorToken || "",
54
+ tool,
55
+ clientTargetDir: options.clientTargetDir || "~/.ai-worklens",
56
+ packageDir: targetDir,
57
+ employeeCount: employees.length,
58
+ employees,
59
+ files: {
60
+ manifest: path.join(targetDir, "rollout-manifest.json"),
61
+ employees: path.join(targetDir, "employees.json"),
62
+ runner: path.join(targetDir, "team-install-runner.mjs"),
63
+ install: path.join(targetDir, "team-install.sh"),
64
+ update: path.join(targetDir, "team-update.sh"),
65
+ check: path.join(targetDir, "team-check.sh"),
66
+ readme: path.join(targetDir, "README.md")
67
+ }
68
+ };
69
+ }
70
+
71
+ export function writeTeamRolloutPackage(options = {}) {
72
+ const manifest = buildTeamRolloutManifest(options);
73
+ fs.mkdirSync(manifest.packageDir, { recursive: true, mode: 0o700 });
74
+ fs.writeFileSync(manifest.files.manifest, `${JSON.stringify({ ...manifest, employees: undefined }, null, 2)}\n`, { mode: 0o600 });
75
+ fs.writeFileSync(manifest.files.employees, `${JSON.stringify(manifest.employees, null, 2)}\n`, { mode: 0o600 });
76
+ fs.writeFileSync(manifest.files.runner, buildRunnerScript(), { mode: 0o700 });
77
+ fs.writeFileSync(manifest.files.install, buildTeamShell("install"), { mode: 0o700 });
78
+ fs.writeFileSync(manifest.files.update, buildTeamShell("update"), { mode: 0o700 });
79
+ fs.writeFileSync(manifest.files.check, buildCheckShell(), { mode: 0o700 });
80
+ fs.writeFileSync(manifest.files.readme, buildReadme(manifest), { mode: 0o600 });
81
+ for (const file of [manifest.files.runner, manifest.files.install, manifest.files.update, manifest.files.check]) {
82
+ fs.chmodSync(file, 0o700);
83
+ }
84
+ return { ok: true, manifest };
85
+ }
86
+
87
+ function buildRunnerScript() {
88
+ return [
89
+ "#!/usr/bin/env node",
90
+ "import fs from \"node:fs\";",
91
+ "import os from \"node:os\";",
92
+ "import path from \"node:path\";",
93
+ `import { installClient } from ${JSON.stringify(path.join(projectRoot, "packages/client-agent/src/install.mjs"))};`,
94
+ "",
95
+ "const packageDir = path.dirname(new URL(import.meta.url).pathname);",
96
+ "const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, \"rollout-manifest.json\"), \"utf8\"));",
97
+ "const employees = JSON.parse(fs.readFileSync(path.join(packageDir, \"employees.json\"), \"utf8\"));",
98
+ "const employeeId = process.argv[2] || process.env.WORKLENS_EMPLOYEE_ID || \"\";",
99
+ "const mode = process.argv[3] || \"install\";",
100
+ "const employee = employees.find((item) => item.employeeId === employeeId || item.id === employeeId || item.pinyinName === employeeId);",
101
+ "if (!employee) {",
102
+ " console.error(JSON.stringify({ ok: false, error: \"employee not found\", employeeId, hint: \"传入员工 ID,例如 ./team-install.sh E001\" }));",
103
+ " process.exit(2);",
104
+ "}",
105
+ "const targetDir = path.join(os.homedir(), \".ai-worklens\");",
106
+ "const result = installClient({",
107
+ " targetDir,",
108
+ " serverUrl: manifest.serverUrl,",
109
+ " collectorToken: manifest.collectorToken || \"\",",
110
+ " employeeId: employee.employeeId,",
111
+ " employeeName: employee.name,",
112
+ " employeePinyin: employee.pinyinName || \"\",",
113
+ " department: employee.department || \"\",",
114
+ " role: employee.role || \"\",",
115
+ " clientId: `${employee.pinyinName || employee.employeeId}-${employee.employeeId}-${manifest.tool}-client`,",
116
+ " tool: manifest.tool",
117
+ "});",
118
+ "console.log(JSON.stringify({ ok: true, mode, employeeId: employee.employeeId, targetDir, generatedFiles: result.generatedFiles }, null, 2));"
119
+ ].join("\n") + "\n";
120
+ }
121
+
122
+ function buildTeamShell(mode) {
123
+ return [
124
+ "#!/usr/bin/env sh",
125
+ "set -eu",
126
+ "EMPLOYEE_ID=\"${1:-${WORKLENS_EMPLOYEE_ID:-}}\"",
127
+ "if [ -z \"$EMPLOYEE_ID\" ]; then",
128
+ " echo \"请传入员工 ID,例如 ./team-install.sh E001\" >&2",
129
+ " exit 2",
130
+ "fi",
131
+ "SCRIPT_DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)",
132
+ `node "$SCRIPT_DIR/team-install-runner.mjs" "$EMPLOYEE_ID" ${mode}`,
133
+ "exec \"$HOME/.ai-worklens/worklens-install-or-update.sh\""
134
+ ].join("\n") + "\n";
135
+ }
136
+
137
+ function buildCheckShell() {
138
+ return [
139
+ "#!/usr/bin/env sh",
140
+ "set -eu",
141
+ "if [ ! -x \"$HOME/.ai-worklens/worklens-self-check.sh\" ]; then",
142
+ " echo \"尚未安装员工端,请先执行 ./team-install.sh <员工ID>\" >&2",
143
+ " exit 2",
144
+ "fi",
145
+ "exec \"$HOME/.ai-worklens/worklens-self-check.sh\""
146
+ ].join("\n") + "\n";
147
+ }
148
+
149
+ function buildReadme(manifest) {
150
+ return [
151
+ "# AI WorkLens 团队推广包",
152
+ "",
153
+ "本目录用于把员工端 MCP、hook、客户端配置和自检脚本推广到团队成员机器。",
154
+ "",
155
+ "## 员工执行",
156
+ "",
157
+ "```bash",
158
+ "./team-install.sh E001",
159
+ "./team-check.sh",
160
+ "```",
161
+ "",
162
+ "后续更新同一套配置:",
163
+ "",
164
+ "```bash",
165
+ "./team-update.sh E001",
166
+ "```",
167
+ "",
168
+ "## 管理员信息",
169
+ "",
170
+ `- 中心端地址:${manifest.serverUrl}`,
171
+ `- 默认工具:${manifest.tool}`,
172
+ `- 员工数量:${manifest.employeeCount}`,
173
+ `- 令牌策略:${manifest.collectorToken ? "已写入员工端配置" : "未配置采集令牌"}`,
174
+ "",
175
+ "## 生成文件",
176
+ "",
177
+ "- rollout-manifest.json:推广配置。",
178
+ "- employees.json:员工清单。",
179
+ "- team-install.sh:员工首次安装。",
180
+ "- team-update.sh:员工更新配置。",
181
+ "- team-check.sh:员工自检。",
182
+ "- team-install-runner.mjs:安装执行器。"
183
+ ].join("\n") + "\n";
184
+ }
185
+
186
+ if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) {
187
+ const args = parseArgs(process.argv.slice(2));
188
+ const result = writeTeamRolloutPackage({
189
+ targetDir: args["target-dir"],
190
+ serverUrl: args["server-url"],
191
+ collectorToken: args["collector-token"],
192
+ tool: args.tool,
193
+ employeesFile: args["employees-file"],
194
+ clientTargetDir: args["client-target-dir"]
195
+ });
196
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
197
+ }
@@ -0,0 +1,428 @@
1
+ import path from "node:path";
2
+ import { EventQueue } from "./queue.mjs";
3
+ import { writeClientConfig } from "./config.mjs";
4
+ import { normalizeCollectionSettings } from "../../collector-protocol/src/collection-settings.mjs";
5
+ import { normalizeClientUpdatePolicy, updateNeeded } from "../../collector-protocol/src/client-update-policy.mjs";
6
+ import { installClient } from "./install.mjs";
7
+
8
+ async function requestJson(url, config, options = {}) {
9
+ const controller = new AbortController();
10
+ const timer = setTimeout(() => controller.abort(), config.upload.timeoutMs);
11
+ try {
12
+ const headers = { "Content-Type": "application/json" };
13
+ if (config.collectorToken) headers.Authorization = `Bearer ${config.collectorToken}`;
14
+ const response = await fetch(new URL(url, config.serverUrl), {
15
+ method: options.method || "GET",
16
+ headers,
17
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
18
+ signal: controller.signal
19
+ });
20
+ const body = await response.json().catch(() => ({}));
21
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
22
+ return body;
23
+ } finally {
24
+ clearTimeout(timer);
25
+ }
26
+ }
27
+
28
+ function postJson(url, payload, config) {
29
+ return requestJson(url, config, { method: "POST", body: payload });
30
+ }
31
+
32
+ function clientConfigFileValue(config, remote) {
33
+ const collection = normalizeCollectionSettings(remote.collection || config.collection || {});
34
+ const update = normalizeClientUpdatePolicy(remote.update || config.update || {});
35
+ return {
36
+ serverUrl: config.serverUrl,
37
+ collectorToken: config.collectorToken,
38
+ clientId: config.clientId,
39
+ clientVersion: config.clientVersion,
40
+ tool: remote.tool || config.tool,
41
+ model: config.model,
42
+ employee: {
43
+ id: remote.employee?.employeeId || config.employee.id,
44
+ name: remote.employee?.name || config.employee.name,
45
+ pinyinName: remote.employee?.pinyinName || config.employee.pinyinName || "",
46
+ department: remote.employee?.department || config.employee.department || "",
47
+ role: remote.employee?.role || config.employee.role || ""
48
+ },
49
+ upload: {
50
+ timeoutMs: config.upload.timeoutMs,
51
+ batchSize: Number(remote.upload?.batchSize || collection.uploadBatchSize || config.upload.batchSize)
52
+ },
53
+ collection,
54
+ update: {
55
+ ...update,
56
+ appliedRevision: config.update?.appliedRevision || "",
57
+ lastUpdatedAt: config.update?.lastUpdatedAt || ""
58
+ },
59
+ lastSyncedAt: remote.generatedAt || new Date().toISOString()
60
+ };
61
+ }
62
+
63
+ export class ClientAgent {
64
+ constructor(config) {
65
+ this.config = config;
66
+ this.queue = new EventQueue(config.queueFile);
67
+ }
68
+
69
+ async record(event) {
70
+ const length = await this.queue.enqueue(event);
71
+ if (this.config.collection?.enabled === false) {
72
+ return {
73
+ ok: true,
74
+ queued: length,
75
+ flush: { ok: false, skipped: "collection_disabled", remaining: length }
76
+ };
77
+ }
78
+ const flush = await this.flush().catch((error) => ({ ok: false, error: error.message }));
79
+ return { ok: true, queued: length, flush };
80
+ }
81
+
82
+ async flush(options = {}) {
83
+ const items = await this.queue.list();
84
+ if (!items.length) return { ok: true, sent: 0, remaining: 0, postponed: 0 };
85
+ if (this.config.collection?.enabled === false) {
86
+ return { ok: false, sent: 0, remaining: items.length, postponed: 0, skipped: "collection_disabled" };
87
+ }
88
+ const batch = await this.queue.due({ limit: this.config.upload.batchSize, force: options.force === true });
89
+ if (!batch.length) {
90
+ const stats = await this.queue.stats();
91
+ return {
92
+ ok: true,
93
+ sent: 0,
94
+ remaining: stats.total,
95
+ postponed: stats.waiting,
96
+ nextAttemptAt: stats.nextAttemptAt
97
+ };
98
+ }
99
+ try {
100
+ const response = await postJson("/api/ingest/events", { events: batch.map((item) => item.event) }, this.config);
101
+ const acceptedItems = acceptedBatchItems(response, batch);
102
+ const accepted = acceptedItems.length;
103
+ const acceptedIds = new Set(acceptedItems.map((item) => item.id));
104
+ const failedItems = batch.filter((item) => !acceptedIds.has(item.id));
105
+ await this.queue.remove(acceptedItems.map((item) => item.id));
106
+ if (failedItems.length) {
107
+ await this.queue.markFailed(failedItems.map((item) => item.id), "partial_upload_not_accepted");
108
+ }
109
+ const stats = await this.queue.stats();
110
+ return {
111
+ ok: failedItems.length === 0,
112
+ sent: accepted,
113
+ remaining: stats.total,
114
+ postponed: stats.waiting,
115
+ nextAttemptAt: stats.nextAttemptAt,
116
+ partial: failedItems.length > 0,
117
+ response
118
+ };
119
+ } catch (error) {
120
+ await this.queue.markFailed(batch.map((item) => item.id), error);
121
+ const stats = await this.queue.stats();
122
+ return {
123
+ ok: false,
124
+ sent: 0,
125
+ remaining: stats.total,
126
+ postponed: stats.waiting,
127
+ nextAttemptAt: stats.nextAttemptAt,
128
+ error: error.message
129
+ };
130
+ }
131
+ }
132
+
133
+ async flushPending(options = {}) {
134
+ const maxBatches = Number(options.maxBatches || 20);
135
+ const results = [];
136
+ let totalSent = 0;
137
+ for (let index = 0; index < maxBatches; index += 1) {
138
+ const result = await this.flush({ force: options.force === true });
139
+ results.push(result);
140
+ totalSent += Number(result.sent || 0);
141
+ if (!result.ok || result.remaining === 0 || result.sent === 0) break;
142
+ }
143
+ const stats = await this.queue.stats();
144
+ const last = results.at(-1) || { ok: true };
145
+ return {
146
+ ok: results.every((item) => item.ok),
147
+ sent: totalSent,
148
+ remaining: stats.total,
149
+ postponed: stats.waiting,
150
+ nextAttemptAt: stats.nextAttemptAt,
151
+ batches: results.length,
152
+ stoppedBy: stats.total === 0 ? "empty" : !last.ok ? "upload_failed" : last.sent === 0 ? "no_due_items" : "batch_limit",
153
+ results
154
+ };
155
+ }
156
+
157
+ async recover(options = {}) {
158
+ const startedAt = new Date().toISOString();
159
+ const synced = options.sync === false
160
+ ? { ok: true, skipped: "sync_disabled" }
161
+ : await this.syncConfig({ write: true }).catch((error) => ({ ok: false, error: error.message }));
162
+ const flush = await this.flushPending({ maxBatches: options.maxBatches || 20, force: options.force === true }).catch((error) => ({ ok: false, sent: 0, error: error.message }));
163
+ const issues = [];
164
+ if (synced.ok === false) issues.push(`sync_failed:${synced.error}`);
165
+ if (flush.ok === false) issues.push(`flush_failed:${flush.error || flush.stoppedBy || "unknown"}`);
166
+ const checkin = options.checkin === false
167
+ ? { ok: true, skipped: "checkin_disabled" }
168
+ : await this.checkin({ issues }).catch((error) => ({ ok: false, error: error.message }));
169
+ const stats = await this.queue.stats();
170
+ return {
171
+ ok: synced.ok !== false && flush.ok !== false && checkin.ok !== false,
172
+ startedAt,
173
+ synced,
174
+ flush,
175
+ checkin,
176
+ queue: stats
177
+ };
178
+ }
179
+
180
+ async checkin(overrides = {}) {
181
+ const queueItems = await this.queue.list();
182
+ return postJson("/api/clients/checkin", {
183
+ employeeId: this.config.employee.id,
184
+ employeeName: this.config.employee.name,
185
+ pinyinName: this.config.employee.pinyinName || "",
186
+ department: this.config.employee.department,
187
+ role: this.config.employee.role,
188
+ clientId: this.config.clientId,
189
+ tool: this.config.tool,
190
+ version: this.config.clientVersion,
191
+ artifactRevision: this.config.update?.appliedRevision || "",
192
+ mcpReady: overrides.mcpReady ?? true,
193
+ hookReady: overrides.hookReady ?? true,
194
+ uploadQueueSize: queueItems.length,
195
+ issues: overrides.issues || []
196
+ }, this.config);
197
+ }
198
+
199
+ async syncConfig(options = {}) {
200
+ const params = new URLSearchParams({
201
+ employeeId: this.config.employee.id,
202
+ clientId: this.config.clientId,
203
+ tool: this.config.tool
204
+ });
205
+ const remote = await requestJson(`/api/client/config?${params.toString()}`, this.config);
206
+ const fileValue = clientConfigFileValue(this.config, remote);
207
+ if (options.write !== false) {
208
+ writeClientConfig(this.config.configFile, fileValue);
209
+ }
210
+ this.config.employee = fileValue.employee;
211
+ this.config.tool = fileValue.tool;
212
+ this.config.upload = fileValue.upload;
213
+ this.config.collection = fileValue.collection;
214
+ this.config.update = fileValue.update;
215
+ return {
216
+ ok: true,
217
+ configFile: this.config.configFile,
218
+ collection: fileValue.collection,
219
+ update: fileValue.update,
220
+ upload: fileValue.upload,
221
+ employee: fileValue.employee,
222
+ wroteConfig: options.write !== false
223
+ };
224
+ }
225
+
226
+ async updateManifest() {
227
+ const params = new URLSearchParams({
228
+ employeeId: this.config.employee.id,
229
+ clientId: this.config.clientId,
230
+ tool: this.config.tool,
231
+ version: this.config.clientVersion,
232
+ artifactRevision: this.config.update?.appliedRevision || ""
233
+ });
234
+ return requestJson(`/api/client/update-manifest?${params.toString()}`, this.config);
235
+ }
236
+
237
+ async autoUpdate(options = {}) {
238
+ const startedAt = new Date().toISOString();
239
+ const beforeVersion = this.config.clientVersion;
240
+ const synced = await this.syncConfig({ write: true }).catch((error) => ({ ok: false, error: error.message }));
241
+ const manifest = await this.updateManifest().catch((error) => ({ ok: false, error: error.message }));
242
+ if (manifest.ok === false) {
243
+ const recovery = await this.recover({ sync: false, maxBatches: 20 }).catch((error) => ({ ok: false, error: error.message }));
244
+ return {
245
+ ok: false,
246
+ skipped: "center_unavailable",
247
+ startedAt,
248
+ beforeVersion,
249
+ error: manifest.error,
250
+ synced,
251
+ recovery
252
+ };
253
+ }
254
+ const policy = normalizeClientUpdatePolicy(manifest.update || {});
255
+ const shouldUpdate = options.force === true || updateNeeded(this.config.clientVersion, this.config.update, policy);
256
+ if (!policy.enabled || !policy.autoUpdate) {
257
+ const recovery = await this.flushPending({ maxBatches: 20 }).catch((error) => ({ ok: false, error: error.message }));
258
+ const issues = recovery.ok === false ? [`flush_failed:${recovery.error || recovery.stoppedBy || "unknown"}`] : [];
259
+ const checkin = await this.checkin({ issues }).catch((error) => ({ ok: false, error: error.message }));
260
+ return {
261
+ ok: true,
262
+ skipped: "auto_update_disabled",
263
+ startedAt,
264
+ beforeVersion,
265
+ policy,
266
+ synced,
267
+ recovery,
268
+ checkin
269
+ };
270
+ }
271
+ if (!shouldUpdate) {
272
+ const recovery = await this.flushPending({ maxBatches: 20 }).catch((error) => ({ ok: false, error: error.message }));
273
+ const issues = recovery.ok === false ? [`flush_failed:${recovery.error || recovery.stoppedBy || "unknown"}`] : [];
274
+ const checkin = await this.checkin({ issues }).catch((error) => ({ ok: false, error: error.message }));
275
+ return {
276
+ ok: true,
277
+ updated: false,
278
+ startedAt,
279
+ beforeVersion,
280
+ afterVersion: this.config.clientVersion,
281
+ policy,
282
+ synced,
283
+ recovery,
284
+ checkin
285
+ };
286
+ }
287
+
288
+ try {
289
+ const updatePolicy = {
290
+ ...policy,
291
+ appliedRevision: policy.artifactRevision,
292
+ lastUpdatedAt: new Date().toISOString()
293
+ };
294
+ const install = installClient({
295
+ targetDir: path.dirname(this.config.configFile),
296
+ serverUrl: this.config.serverUrl,
297
+ collectorToken: this.config.collectorToken,
298
+ employeeId: this.config.employee.id,
299
+ employeeName: this.config.employee.name,
300
+ employeePinyin: this.config.employee.pinyinName || "",
301
+ department: this.config.employee.department,
302
+ role: this.config.employee.role,
303
+ clientId: this.config.clientId,
304
+ clientVersion: policy.clientVersion,
305
+ tool: this.config.tool,
306
+ modelProvider: this.config.model?.provider || "",
307
+ modelName: this.config.model?.name || "",
308
+ modelVersion: this.config.model?.version || "",
309
+ modelFamily: this.config.model?.family || "",
310
+ upload: this.config.upload,
311
+ collection: this.config.collection,
312
+ updatePolicy
313
+ });
314
+ this.config.clientVersion = policy.clientVersion;
315
+ this.config.update = updatePolicy;
316
+ const recovery = await this.flushPending({ maxBatches: 20 }).catch((error) => ({ ok: false, error: error.message }));
317
+ const issues = recovery.ok === false ? [`flush_failed:${recovery.error || recovery.stoppedBy || "unknown"}`] : [];
318
+ const checkin = await this.checkin({ issues }).catch((error) => ({ ok: false, error: error.message }));
319
+ return {
320
+ ok: true,
321
+ updated: true,
322
+ startedAt,
323
+ beforeVersion,
324
+ afterVersion: this.config.clientVersion,
325
+ appliedRevision: updatePolicy.appliedRevision,
326
+ generatedFiles: install.generatedFiles,
327
+ policy,
328
+ synced,
329
+ recovery,
330
+ checkin
331
+ };
332
+ } catch (error) {
333
+ const issue = `update_failed:${error.message}`;
334
+ const checkin = await this.checkin({ issues: [issue] }).catch((checkinError) => ({ ok: false, error: checkinError.message }));
335
+ return {
336
+ ok: false,
337
+ updated: false,
338
+ startedAt,
339
+ beforeVersion,
340
+ policy,
341
+ error: error.message,
342
+ checkin
343
+ };
344
+ }
345
+ }
346
+
347
+ async status() {
348
+ const queueItems = await this.queue.list();
349
+ const queueStats = await this.queue.stats();
350
+ return {
351
+ serverUrl: this.config.serverUrl,
352
+ employee: this.config.employee,
353
+ clientId: this.config.clientId,
354
+ configFile: this.config.configFile,
355
+ queueFile: this.config.queueFile,
356
+ queueSize: queueItems.length,
357
+ queue: queueStats,
358
+ collection: this.config.collection,
359
+ update: this.config.update
360
+ };
361
+ }
362
+
363
+ async health() {
364
+ return requestJson("/api/health", this.config);
365
+ }
366
+
367
+ async doctor() {
368
+ const status = await this.status();
369
+ const checks = [];
370
+ checks.push(check("employee_configured", Boolean(this.config.employee.id), "缺少员工编号"));
371
+ checks.push(check("client_configured", Boolean(this.config.clientId), "缺少客户端编号"));
372
+ checks.push(check("tool_configured", Boolean(this.config.tool), "缺少 AI 工具类型"));
373
+ checks.push(check("server_url_configured", Boolean(this.config.serverUrl), "缺少中心端地址"));
374
+
375
+ let centerHealth = null;
376
+ try {
377
+ centerHealth = await this.health();
378
+ checks.push(check("center_reachable", true));
379
+ } catch (error) {
380
+ checks.push(check("center_reachable", false, `中心端不可达:${error.message}`));
381
+ }
382
+
383
+ const queueWarnThreshold = Number(this.config.collection?.queueWarnThreshold || 1000);
384
+ checks.push(check(
385
+ "queue_below_threshold",
386
+ status.queueSize < queueWarnThreshold,
387
+ `离线队列 ${status.queueSize} 条,已达到预警阈值 ${queueWarnThreshold}`
388
+ ));
389
+ checks.push(check(
390
+ "collection_enabled",
391
+ this.config.collection?.enabled !== false,
392
+ "中心端已关闭采集,事件会保留在本地队列"
393
+ ));
394
+ checks.push(check(
395
+ "auto_update_enabled",
396
+ this.config.update?.enabled !== false && this.config.update?.autoUpdate !== false,
397
+ "中心端已关闭客户端静默更新"
398
+ ));
399
+
400
+ const issues = checks.filter((item) => !item.ok).map((item) => item.message);
401
+ return {
402
+ ok: issues.length === 0,
403
+ checkedAt: new Date().toISOString(),
404
+ checks,
405
+ issues,
406
+ centerHealth,
407
+ status
408
+ };
409
+ }
410
+ }
411
+
412
+ function check(name, ok, message = "") {
413
+ return { name, ok: Boolean(ok), message: ok ? "" : message };
414
+ }
415
+
416
+ function clampAccepted(value, batchLength) {
417
+ const accepted = Number(value);
418
+ if (!Number.isFinite(accepted)) return batchLength;
419
+ return Math.min(batchLength, Math.max(0, Math.floor(accepted)));
420
+ }
421
+
422
+ function acceptedBatchItems(response, batch) {
423
+ if (Array.isArray(response?.eventIds) && response.eventIds.length) {
424
+ const eventIds = new Set(response.eventIds.map(String));
425
+ return batch.filter((item) => eventIds.has(String(item.event?.eventId)));
426
+ }
427
+ return batch.slice(0, clampAccepted(response?.accepted, batch.length));
428
+ }