@ynhcj/xiaoyi-channel 0.0.217-beta → 0.0.217-next

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.
@@ -3,16 +3,96 @@
3
3
  // calls Gateway cron RPC via callGatewayTool, and sends the
4
4
  // result back to the client via sendCommand as a System.CronQuery
5
5
  // command with the result in payload.ans.
6
+ //
7
+ // This module adapts between the XY Channel device-facing RPC schema
8
+ // and the OpenClaw Gateway native cron RPC (protocol v4).
9
+ // Each action transforms request params from device format → gateway format,
10
+ // then transforms the gateway response → device format before sending back.
6
11
  import { callGatewayTool } from "openclaw/plugin-sdk/agent-harness-runtime";
7
- import * as os from "os";
12
+ import fsp from "node:fs/promises";
13
+ import os from "node:os";
14
+ import path from "node:path";
8
15
  import { sendCommand } from "./formatter.js";
9
16
  import { resolveXYConfig } from "./config.js";
10
17
  import { configManager } from "./utils/config-manager.js";
11
18
  import { setJobPushId } from "./utils/cron-push-map.js";
12
19
  import { logger } from "./utils/logger.js";
13
- import { readFileSync, readdirSync } from "fs";
14
- import { join } from "path";
20
+ // ── Local run-log constants, types, and parsers (moved from cron-recovery) ───
21
+ const ROOT_DIR = path.join(os.homedir(), ".openclaw");
22
+ /** Path to the legacy cron run-log directory. */
23
+ const LEGACY_CRON_RUNS_DIR = path.join(ROOT_DIR, "cron", "runs");
24
+ function isRecord(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ function readString(record, key) {
28
+ const val = record[key];
29
+ return typeof val === "string" && val.trim() ? val.trim() : undefined;
30
+ }
31
+ function readOptionalString(record, key) {
32
+ const val = record[key];
33
+ return typeof val === "string" ? val : undefined;
34
+ }
35
+ function readNumber(record, key) {
36
+ const val = record[key];
37
+ return typeof val === "number" && Number.isFinite(val) ? val : undefined;
38
+ }
39
+ function readBoolean(record, key) {
40
+ const val = record[key];
41
+ return typeof val === "boolean" ? val : undefined;
42
+ }
43
+ function parseRunLogEntry(obj, opts) {
44
+ if (!isRecord(obj))
45
+ return null;
46
+ const ts = readNumber(obj, "ts");
47
+ if (ts === undefined)
48
+ return null;
49
+ const jobId = opts?.jobId ?? readString(obj, "jobId");
50
+ if (!jobId)
51
+ return null;
52
+ return {
53
+ jobId,
54
+ ts,
55
+ runId: readOptionalString(obj, "runId"),
56
+ status: readOptionalString(obj, "status"),
57
+ error: readOptionalString(obj, "error"),
58
+ summary: readOptionalString(obj, "summary"),
59
+ diagnosticsSummary: readOptionalString(obj, "diagnosticsSummary"),
60
+ deliveryStatus: readOptionalString(obj, "deliveryStatus"),
61
+ deliveryError: readOptionalString(obj, "deliveryError"),
62
+ delivered: readBoolean(obj, "delivered"),
63
+ sessionId: readOptionalString(obj, "sessionId"),
64
+ sessionKey: readOptionalString(obj, "sessionKey"),
65
+ runAtMs: readNumber(obj, "runAtMs"),
66
+ durationMs: readNumber(obj, "durationMs"),
67
+ nextRunAtMs: readNumber(obj, "nextRunAtMs"),
68
+ model: readOptionalString(obj, "model"),
69
+ provider: readOptionalString(obj, "provider"),
70
+ totalTokens: readNumber(obj, "totalTokens"),
71
+ };
72
+ }
73
+ function parseCronRunLogEntriesFromJsonl(raw, opts) {
74
+ if (!raw.trim())
75
+ return [];
76
+ const entries = [];
77
+ for (const line of raw.split("\n")) {
78
+ const trimmed = line.trim();
79
+ if (!trimmed)
80
+ continue;
81
+ try {
82
+ const entry = parseRunLogEntry(JSON.parse(trimmed), opts);
83
+ if (entry)
84
+ entries.push(entry);
85
+ }
86
+ catch {
87
+ // Skip malformed historical rows
88
+ }
89
+ }
90
+ return entries;
91
+ }
15
92
  const GATEWAY_TIMEOUT_MS = 60_000;
93
+ // ============================================================================
94
+ // Main handler
95
+ // ============================================================================
16
96
  /**
17
97
  * Handle a cron-query-event.
18
98
  *
@@ -27,47 +107,111 @@ export async function handleCronQueryEvent(context, cfg) {
27
107
  let error;
28
108
  try {
29
109
  switch (action) {
30
- case "list":
31
- result = await callGatewayTool("cron.list", { timeoutMs: GATEWAY_TIMEOUT_MS }, params ?? {});
110
+ // ── list ──────────────────────────────────────────────────────
111
+ case "list": {
112
+ const gatewayParams = buildListParams(params);
113
+ let gatewayResult = await callGatewayTool("cron.list", { timeoutMs: GATEWAY_TIMEOUT_MS }, gatewayParams);
114
+ // Scan for systemEvent jobs and migrate them to agentTurn
115
+ // Skip main-session jobs — gateway requires payload.kind="systemEvent" for those
116
+ const jobs = gatewayResult?.jobs ?? [];
117
+ const systemEventJobs = jobs.filter((j) => j?.payload?.kind === "systemEvent");
118
+ if (systemEventJobs.length > 0) {
119
+ log.log(`[CRON-QUERY] list: found ${systemEventJobs.length} systemEvent jobs, migrating to agentTurn`);
120
+ for (const job of systemEventJobs) {
121
+ const updatePatch = {
122
+ sessionTarget: "isolated",
123
+ description: job.payload?.text ?? job.payload?.message ?? "",
124
+ payload: {
125
+ kind: "agentTurn",
126
+ message: job.payload?.text ?? job.payload?.message ?? "",
127
+ },
128
+ delivery: {
129
+ channel: "xiaoyi-channel",
130
+ mode: "announce",
131
+ to: "default"
132
+ }
133
+ };
134
+ try {
135
+ await callGatewayTool("cron.update", { timeoutMs: GATEWAY_TIMEOUT_MS }, { id: job.id, patch: updatePatch });
136
+ log.log(`[CRON-QUERY] list: migrated job ${job.id} (${job.name ?? ""}) from systemEvent → agentTurn`);
137
+ }
138
+ catch (err) {
139
+ log.warn(`[CRON-QUERY] list: failed to migrate job ${job.id}:`, err);
140
+ }
141
+ }
142
+ // Re-fetch list after migration
143
+ gatewayResult = await callGatewayTool("cron.list", { timeoutMs: GATEWAY_TIMEOUT_MS }, gatewayParams);
144
+ }
145
+ result = transformListResponse(gatewayResult, params);
32
146
  break;
33
- case "status":
34
- result = await callGatewayTool("cron.status", { timeoutMs: GATEWAY_TIMEOUT_MS }, {});
147
+ }
148
+ // ── status ────────────────────────────────────────────────────
149
+ case "status": {
150
+ const gatewayResult = await callGatewayTool("cron.status", { timeoutMs: GATEWAY_TIMEOUT_MS }, {});
151
+ result = transformStatusResponse(gatewayResult);
35
152
  break;
36
- case "runs":
37
- result = await callGatewayTool("cron.runs", { timeoutMs: GATEWAY_TIMEOUT_MS }, {
38
- jobId,
39
- ...params,
40
- });
153
+ }
154
+ // ── runs ─────────────────────────────────────────────────────
155
+ case "runs": {
156
+ const { offset = 0, limit = 50, ...otherParams } = params ?? {};
157
+ // Fetch from local runs folder and gateway RPC in parallel
158
+ const [localEntries, gatewayResult] = await Promise.all([
159
+ readLocalRunLogsForJob(jobId).catch(() => []),
160
+ callGatewayTool("cron.runs", { timeoutMs: GATEWAY_TIMEOUT_MS }, { jobId, scope: "job", ...otherParams }).catch(() => ({ entries: [] })),
161
+ ]);
162
+ const gatewayEntries = gatewayResult?.entries ?? [];
163
+ // Merge, deduplicate, and sort by time (newest first)
164
+ const merged = mergeAndDedupeRunEntries(localEntries, gatewayEntries);
165
+ // Apply pagination after merge
166
+ const total = merged.length;
167
+ const paged = merged.slice(offset, offset + limit);
168
+ const hasMore = total > offset + limit;
169
+ result = {
170
+ entries: paged,
171
+ total,
172
+ offset,
173
+ limit,
174
+ hasMore,
175
+ nextOffset: hasMore ? offset + limit : null,
176
+ };
41
177
  break;
42
- case "add":
43
- result = await callGatewayTool("cron.add", { timeoutMs: GATEWAY_TIMEOUT_MS }, params ?? {});
178
+ }
179
+ // ── add ──────────────────────────────────────────────────────
180
+ case "add": {
181
+ const gatewayParams = buildAddParams(params);
182
+ const gatewayResult = await callGatewayTool("cron.add", { timeoutMs: GATEWAY_TIMEOUT_MS }, gatewayParams);
44
183
  // 捕获 jobId↔pushId:cron-query 路径由 channel 自己建 job,
45
184
  // 此处 context 握着 sessionId,configManager 有对应设备 pushId。
46
- await persistCronPushMap(context.sessionId, result).catch((err) => {
185
+ await persistCronPushMap(context.sessionId, gatewayResult).catch((err) => {
47
186
  logger.error(`[CRON-QUERY] Failed to persist cron-push-map:`, err);
48
187
  });
188
+ result = transformAddResponse(gatewayResult);
49
189
  break;
50
- case "update":
51
- result = await callGatewayTool("cron.update", { timeoutMs: GATEWAY_TIMEOUT_MS }, {
52
- jobId,
53
- ...params,
54
- });
190
+ }
191
+ // ── update ───────────────────────────────────────────────────
192
+ case "update": {
193
+ const gatewayParams = buildUpdateParams(jobId, params);
194
+ const gatewayResult = await callGatewayTool("cron.update", { timeoutMs: GATEWAY_TIMEOUT_MS }, gatewayParams);
195
+ result = transformUpdateResponse(gatewayResult);
55
196
  break;
56
- case "remove":
57
- result = await callGatewayTool("cron.remove", { timeoutMs: GATEWAY_TIMEOUT_MS }, {
58
- jobId,
59
- });
197
+ }
198
+ // ── remove ───────────────────────────────────────────────────
199
+ case "remove": {
200
+ const gatewayResult = await callGatewayTool("cron.remove", { timeoutMs: GATEWAY_TIMEOUT_MS }, { id: jobId });
201
+ result = transformRemoveResponse(gatewayResult);
60
202
  break;
61
- case "run":
62
- result = await callGatewayTool("cron.run", { timeoutMs: GATEWAY_TIMEOUT_MS }, {
63
- jobId,
64
- mode: "force",
65
- ...params,
66
- });
203
+ }
204
+ // ── run ──────────────────────────────────────────────────────
205
+ case "run": {
206
+ const gatewayResult = await callGatewayTool("cron.run", { timeoutMs: GATEWAY_TIMEOUT_MS }, { id: jobId, mode: "force" });
207
+ result = transformRunResponse(gatewayResult);
67
208
  break;
68
- case "queryTimeList":
69
- result = await queryTimeListLocal();
209
+ }
210
+ // ── queryTimeList ────────────────────────────────────────────
211
+ case "queryTimeList": {
212
+ result = await queryTimeListFromGateway(params);
70
213
  break;
214
+ }
71
215
  default:
72
216
  error = `Unknown action: ${context.action}`;
73
217
  log.error(`[CRON-QUERY] ${error}`);
@@ -81,7 +225,7 @@ export async function handleCronQueryEvent(context, cfg) {
81
225
  }
82
226
  // Log the result
83
227
  log.log(`[CRON-QUERY] RPC result for action=${action}: ${JSON.stringify(result, null, 2)}`);
84
- // Send result back via sendCommand as System.CronQuery with payload.ans
228
+ // Send result back via sendCommand as AgentEvent.CronQuery with payload.ans
85
229
  if (cfg && sessionId && taskId && messageId) {
86
230
  try {
87
231
  const config = resolveXYConfig(cfg);
@@ -92,6 +236,7 @@ export async function handleCronQueryEvent(context, cfg) {
92
236
  },
93
237
  payload: {
94
238
  action,
239
+ status: !error,
95
240
  ans: result,
96
241
  },
97
242
  };
@@ -113,6 +258,114 @@ export async function handleCronQueryEvent(context, cfg) {
113
258
  log.warn(`[CRON-QUERY] Missing cfg/sessionId/taskId/messageId, skipping sendCommand`);
114
259
  }
115
260
  }
261
+ // ============================================================================
262
+ // Request builders — device params → gateway params
263
+ // ============================================================================
264
+ /** list: device params pass through (gateway-compatible). */
265
+ function buildListParams(params) {
266
+ return params ?? {};
267
+ }
268
+ /** add: unwrap params.job → top-level gateway params. */
269
+ function buildAddParams(params) {
270
+ const job = params?.job ?? params ?? {};
271
+ return {
272
+ name: job.name,
273
+ schedule: job.schedule,
274
+ sessionTarget: job.sessionTarget,
275
+ wakeMode: job.wakeMode,
276
+ payload: job.payload,
277
+ enabled: job.enabled,
278
+ deleteAfterRun: job.deleteAfterRun,
279
+ delivery: job.delivery,
280
+ description: job.description,
281
+ failureAlert: job.failureAlert,
282
+ };
283
+ }
284
+ /** update: unwrap params.patch → patch, add jobId. */
285
+ function buildUpdateParams(jobId, params) {
286
+ const patch = params?.patch ?? params ?? {};
287
+ return {
288
+ id: jobId,
289
+ patch
290
+ };
291
+ }
292
+ // ============================================================================
293
+ // Response transformers — gateway result → device format
294
+ // ============================================================================
295
+ /** Compute pagination metadata from request params and total. */
296
+ function computePagination(params, total) {
297
+ const offset = params?.offset ?? 0;
298
+ const limit = params?.limit ?? total;
299
+ const hasMore = total > offset + limit;
300
+ const nextOffset = hasMore ? offset + limit : null;
301
+ return { offset, limit, hasMore, nextOffset };
302
+ }
303
+ /** list: add pagination fields + status. */
304
+ function transformListResponse(gatewayResult, params) {
305
+ const jobs = gatewayResult?.jobs ?? [];
306
+ const total = gatewayResult?.total ?? jobs.length;
307
+ const pagination = computePagination(params, total);
308
+ return {
309
+ jobs,
310
+ total,
311
+ offset: pagination.offset,
312
+ limit: pagination.limit,
313
+ hasMore: pagination.hasMore,
314
+ nextOffset: pagination.nextOffset,
315
+ deliveryPreviews: gatewayResult?.deliveryPreviews ?? {},
316
+ };
317
+ }
318
+ /** status: rename nextRunAtMs → nextWakeAtMs, add storePath. */
319
+ function transformStatusResponse(gatewayResult) {
320
+ return {
321
+ enabled: gatewayResult?.enabled ?? false,
322
+ storePath: ".openclaw/cron/jobs.json",
323
+ jobs: gatewayResult?.jobs ?? 0,
324
+ nextWakeAtMs: gatewayResult?.nextRunAtMs ?? null,
325
+ };
326
+ }
327
+ /** add: pick relevant fields from gateway CronJob response. */
328
+ function transformAddResponse(gatewayResult) {
329
+ return {
330
+ id: gatewayResult?.id,
331
+ name: gatewayResult?.name,
332
+ enabled: gatewayResult?.enabled,
333
+ description: gatewayResult?.payload?.message,
334
+ createdAtMs: gatewayResult?.createdAtMs,
335
+ updatedAtMs: gatewayResult?.updatedAtMs,
336
+ schedule: gatewayResult?.schedule,
337
+ sessionTarget: gatewayResult?.sessionTarget,
338
+ wakeMode: gatewayResult?.wakeMode,
339
+ payload: gatewayResult?.payload,
340
+ delivery: gatewayResult?.delivery,
341
+ state: {
342
+ nextRunAtMs: gatewayResult?.state?.nextRunAtMs ?? null,
343
+ },
344
+ };
345
+ }
346
+ /** update: same structure as add. */
347
+ function transformUpdateResponse(gatewayResult) {
348
+ return transformAddResponse(gatewayResult);
349
+ }
350
+ /** remove: wrap with ok flag. */
351
+ function transformRemoveResponse(gatewayResult) {
352
+ return {
353
+ ok: gatewayResult?.removed ?? false,
354
+ removed: gatewayResult?.removed ?? false,
355
+ };
356
+ }
357
+ /** run: map ran → enqueued. */
358
+ function transformRunResponse(gatewayResult) {
359
+ return {
360
+ ok: gatewayResult?.ok ?? false,
361
+ runId: gatewayResult?.runId ?? null,
362
+ enqueued: gatewayResult?.ran ?? false,
363
+ error: gatewayResult?.error ?? undefined,
364
+ };
365
+ }
366
+ // ============================================================================
367
+ // Supporting functions
368
+ // ============================================================================
116
369
  /**
117
370
  * 从 cron.add 结果中提取 jobId,配合 sessionId 对应的 pushId 写入映射。
118
371
  */
@@ -129,7 +382,9 @@ async function persistCronPushMap(sessionId, result) {
129
382
  jobId = id.trim();
130
383
  }
131
384
  if (!jobId) {
132
- const preview = typeof result === "string" ? result.slice(0, 200) : JSON.stringify(result)?.slice(0, 200);
385
+ const preview = typeof result === "string"
386
+ ? result.slice(0, 200)
387
+ : JSON.stringify(result)?.slice(0, 200);
133
388
  logger.log(`[CRONMAP] cron-query skip: no jobId in result. preview=${preview ?? "(empty)"}`);
134
389
  return;
135
390
  }
@@ -143,72 +398,46 @@ async function persistCronPushMap(sessionId, result) {
143
398
  logger.log(`[CRONMAP] cron-query map written OK`);
144
399
  }
145
400
  /**
146
- * Read local cron folder directly (bypassing openclaw RPC) and return
147
- * run records from the last 7 days, grouped by date and sorted by time.
148
- *
149
- * Data sources:
150
- * - state/cron/jobs.json → job id → name mapping
151
- * - state/cron/runs/*.jsonl → run records (one JSON per line)
401
+ * Query run history from the last 7 days from both local runs folder
402
+ * and the gateway's native cron.runs RPC, merged and grouped by date.
152
403
  *
153
404
  * Return format:
154
405
  * [ { "YYYY-MM-DD": [ { run record with .name }, ... ] }, ... ]
155
406
  */
156
- async function queryTimeListLocal() {
157
- const cronDir = join(os.homedir(), ".openclaw", "cron");
158
- const jobsPath = join(cronDir, "jobs.json");
159
- const runsDir = join(cronDir, "runs");
160
- // 1. Build jobId name map from jobs.json
161
- const jobNameMap = {};
162
- try {
163
- const jobsRaw = readFileSync(jobsPath, "utf-8");
164
- const jobsData = JSON.parse(jobsRaw);
165
- for (const job of jobsData.jobs || []) {
166
- jobNameMap[job.id] = job.name || job.id;
167
- }
168
- }
169
- catch (err) {
170
- logger.error(`[CRON-QUERY] Failed to read jobs.json: ${err.message}`);
407
+ async function queryTimeListFromGateway(params) {
408
+ // Fetch from local runs folder and gateway RPC in parallel
409
+ const [localEntries, rpcResult] = await Promise.all([
410
+ readAllLocalRunLogs().catch(() => []),
411
+ callGatewayTool("cron.runs", { timeoutMs: GATEWAY_TIMEOUT_MS }, {
412
+ scope: "all",
413
+ limit: 200,
414
+ sortDir: "desc",
415
+ ...(params ?? {}),
416
+ }).catch(() => ({ entries: [] })),
417
+ ]);
418
+ const gatewayEntries = rpcResult?.entries ?? [];
419
+ // Merge and deduplicate
420
+ const merged = mergeAndDedupeRunEntries(localEntries, gatewayEntries);
421
+ if (merged.length === 0) {
422
+ return [];
171
423
  }
172
- // 2. Read all run files, collect runs within last 7 days
424
+ // Filter to last 7 days
173
425
  const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
174
- const allRuns = [];
175
- let files = [];
176
- try {
177
- files = readdirSync(runsDir);
178
- }
179
- catch {
180
- files = [];
181
- }
182
- for (const file of files) {
183
- if (!file.endsWith(".jsonl"))
184
- continue;
185
- try {
186
- const content = readFileSync(join(runsDir, file), "utf-8");
187
- const lines = content.trim().split("\n");
188
- for (const line of lines) {
189
- if (!line.trim())
190
- continue;
191
- try {
192
- const run = JSON.parse(line);
193
- if (run.ts && run.ts >= sevenDaysAgo) {
194
- run.name = jobNameMap[run.jobId] || run.jobId || "";
195
- allRuns.push(run);
196
- }
197
- }
198
- catch {
199
- // skip malformed line
200
- }
201
- }
426
+ const recent = merged.filter((e) => e.ts && e.ts >= sevenDaysAgo);
427
+ // Ensure .name is populated (gateway returns jobName; normalize to .name)
428
+ for (const run of recent) {
429
+ if (!run.name && run.jobName) {
430
+ run.name = run.jobName;
202
431
  }
203
- catch (err) {
204
- logger.error(`[CRON-QUERY] Failed to read run file ${file}: ${err.message}`);
432
+ else if (!run.name) {
433
+ run.name = run.jobId || "";
205
434
  }
206
435
  }
207
- // 3. Sort by ts ascending
208
- allRuns.sort((a, b) => a.ts - b.ts);
209
- // 4. Group by date (YYYY-MM-DD in local time)
436
+ // Sort by ts ascending for chronological display
437
+ recent.sort((a, b) => a.ts - b.ts);
438
+ // Group by date (YYYY-MM-DD in local time)
210
439
  const grouped = new Map();
211
- for (const run of allRuns) {
440
+ for (const run of recent) {
212
441
  const d = new Date(run.ts);
213
442
  const label = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
214
443
  if (!grouped.has(label)) {
@@ -216,10 +445,171 @@ async function queryTimeListLocal() {
216
445
  }
217
446
  grouped.get(label).push(run);
218
447
  }
219
- // 5. Convert to ordered array of single-key objects
448
+ // Convert to ordered array of single-key objects
220
449
  const result = [];
221
450
  for (const [date, runs] of grouped) {
222
451
  result.push({ [date]: runs });
223
452
  }
224
453
  return result;
225
454
  }
455
+ // ============================================================================
456
+ // Local run-log helpers — read legacy runs/*.jsonl files
457
+ // ============================================================================
458
+ /**
459
+ * Filename patterns that may contain legacy run-log data.
460
+ *
461
+ * After cron-recovery migration (gateway_start hook), .jsonl files are
462
+ * archived to .jsonl.migrated (or .migrated.2, .migrated.3, …).
463
+ * We must match all of these so that historical runs are still visible
464
+ * in queryTimeList after migration.
465
+ */
466
+ const RUN_LOG_SUFFIXES = [
467
+ ".jsonl", // active / pre-migration
468
+ ".jsonl.migrated", // first migration
469
+ ];
470
+ /**
471
+ * Check whether a filename looks like a run-log file (any generation).
472
+ */
473
+ function isRunLogFile(name) {
474
+ return RUN_LOG_SUFFIXES.some((suffix) => name.endsWith(suffix))
475
+ // Also catch .migrated.2, .migrated.3, …
476
+ || /\.jsonl\.migrated\.\d+$/.test(name);
477
+ }
478
+ /**
479
+ * Strip known run-log suffixes to extract the bare jobId from a filename.
480
+ * e.g. "abc123.jsonl.migrated.2" → "abc123"
481
+ */
482
+ function extractJobIdFromRunLogName(name) {
483
+ // Try each known suffix first
484
+ for (const suffix of RUN_LOG_SUFFIXES) {
485
+ if (name.endsWith(suffix)) {
486
+ return name.slice(0, -suffix.length);
487
+ }
488
+ }
489
+ // Fallback: strip .jsonl and everything after
490
+ const dotJsonl = name.indexOf(".jsonl");
491
+ if (dotJsonl >= 0) {
492
+ return name.slice(0, dotJsonl);
493
+ }
494
+ // Last resort: just use the basename without extension
495
+ return path.basename(name, path.extname(name));
496
+ }
497
+ /**
498
+ * Read local run-log entries for a single job from the legacy runs folder.
499
+ *
500
+ * Tries multiple filename suffixes (active + archived) since migration
501
+ * may have renamed the file to .jsonl.migrated.
502
+ */
503
+ async function readLocalRunLogsForJob(jobId) {
504
+ // Try active file first, then archived variants
505
+ const candidates = [
506
+ `${jobId}.jsonl`,
507
+ `${jobId}.jsonl.migrated`,
508
+ ];
509
+ for (const candidate of candidates) {
510
+ const filePath = path.join(LEGACY_CRON_RUNS_DIR, candidate);
511
+ try {
512
+ const raw = await fsp.readFile(filePath, "utf-8");
513
+ if (raw.trim()) {
514
+ return parseCronRunLogEntriesFromJsonl(raw, { jobId });
515
+ }
516
+ }
517
+ catch {
518
+ // Try next candidate
519
+ }
520
+ }
521
+ // Also scan for .migrated.N variants
522
+ try {
523
+ const dirEntries = await fsp.readdir(LEGACY_CRON_RUNS_DIR, { withFileTypes: true });
524
+ const migratedRe = new RegExp(`^${escapeRegex(jobId)}\\.jsonl\\.migrated\\.\\d+$`);
525
+ for (const entry of dirEntries) {
526
+ if (entry.isFile() && migratedRe.test(entry.name)) {
527
+ try {
528
+ const raw = await fsp.readFile(path.join(LEGACY_CRON_RUNS_DIR, entry.name), "utf-8");
529
+ if (raw.trim()) {
530
+ return parseCronRunLogEntriesFromJsonl(raw, { jobId });
531
+ }
532
+ }
533
+ catch {
534
+ // Keep trying other candidates
535
+ }
536
+ }
537
+ }
538
+ }
539
+ catch {
540
+ // Directory listing failed, give up
541
+ }
542
+ return [];
543
+ }
544
+ /**
545
+ * Read all local run-log entries from the legacy runs folder.
546
+ *
547
+ * Matches .jsonl files as well as post-migration archived copies
548
+ * (.jsonl.migrated, .jsonl.migrated.2, …).
549
+ */
550
+ async function readAllLocalRunLogs() {
551
+ try {
552
+ const entries = await fsp.readdir(LEGACY_CRON_RUNS_DIR, { withFileTypes: true });
553
+ const runLogFiles = entries.filter((e) => e.isFile() && isRunLogFile(e.name));
554
+ const allEntries = [];
555
+ for (const file of runLogFiles) {
556
+ const jobId = extractJobIdFromRunLogName(file.name);
557
+ try {
558
+ const raw = await fsp.readFile(path.join(LEGACY_CRON_RUNS_DIR, file.name), "utf-8");
559
+ const parsed = parseCronRunLogEntriesFromJsonl(raw, { jobId });
560
+ allEntries.push(...parsed);
561
+ }
562
+ catch {
563
+ // Skip unreadable files
564
+ }
565
+ }
566
+ return allEntries;
567
+ }
568
+ catch {
569
+ return [];
570
+ }
571
+ }
572
+ /** Escape special regex characters in a string. */
573
+ function escapeRegex(s) {
574
+ return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
575
+ }
576
+ /**
577
+ * Build a dedup key for a run-log entry.
578
+ */
579
+ function makeRunEntryKey(entry) {
580
+ return [
581
+ entry.jobId,
582
+ String(entry.ts),
583
+ entry.runId ?? "",
584
+ entry.status ?? "",
585
+ entry.summary ?? "",
586
+ entry.error ?? "",
587
+ ].join("\0");
588
+ }
589
+ /**
590
+ * Merge local and gateway run entries, deduplicate, and sort by ts descending.
591
+ * Gateway entries take precedence over local entries with the same key.
592
+ */
593
+ function mergeAndDedupeRunEntries(localEntries, gatewayEntries) {
594
+ const seen = new Set();
595
+ const merged = [];
596
+ // Add gateway entries first (they take precedence)
597
+ for (const entry of gatewayEntries) {
598
+ const key = makeRunEntryKey(entry);
599
+ if (!seen.has(key)) {
600
+ seen.add(key);
601
+ merged.push(entry);
602
+ }
603
+ }
604
+ // Add local entries that aren't already in gateway results
605
+ for (const entry of localEntries) {
606
+ const key = makeRunEntryKey(entry);
607
+ if (!seen.has(key)) {
608
+ seen.add(key);
609
+ merged.push(entry);
610
+ }
611
+ }
612
+ // Sort by ts descending (newest first)
613
+ merged.sort((a, b) => b.ts - a.ts);
614
+ return merged;
615
+ }