@morehao/dsh-github-trending 0.5.1 → 0.6.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.
@@ -74,9 +74,14 @@ var DEFAULT_SETTINGS = {
74
74
  includeDrafts: false,
75
75
  fallbackToTags: true,
76
76
  baselineOnFirstRun: true,
77
- perRepoLimit: 5,
77
+ perRepoLimit: 10,
78
78
  maxNotesChars: 4e3,
79
- period: "daily"
79
+ summaryMaxChars: 1200,
80
+ docMaxLines: 600,
81
+ concurrency: "auto",
82
+ budgetSeconds: "auto",
83
+ period: "weekly",
84
+ schedule: { enabled: true, weekday: 1, time: "08:00:00", timezone: "Asia/Shanghai" }
80
85
  },
81
86
  output: { dir: "trending" },
82
87
  schedule: { enabled: true, time: "08:30:00", timezone: "Asia/Shanghai", periods: ["daily", "weekly", "monthly"] },
@@ -148,6 +153,11 @@ function strArray(value, fallback) {
148
153
  const items = value.filter((item) => typeof item === "string" && item.trim() !== "");
149
154
  return items.length > 0 ? items.map((item) => item.trim()) : fallback;
150
155
  }
156
+ function autoNumber(value, fallback) {
157
+ if (value === "auto") return "auto";
158
+ if (typeof value === "number" && Number.isFinite(value)) return Math.round(value);
159
+ return fallback;
160
+ }
151
161
  function periodList(_value, _fallback) {
152
162
  return [...PERIODS];
153
163
  }
@@ -160,6 +170,7 @@ function normalizeSettings(input) {
160
170
  const trending = raw.trending ?? {};
161
171
  const summarize = raw.summarize ?? {};
162
172
  const releases = raw.releases ?? {};
173
+ const releasesSchedule = releases.schedule ?? {};
163
174
  const output = raw.output ?? {};
164
175
  const schedule = raw.schedule ?? {};
165
176
  const commitMode = str(repo.commitMode, DEFAULT_SETTINGS.repo.commitMode);
@@ -220,7 +231,17 @@ function normalizeSettings(input) {
220
231
  baselineOnFirstRun: bool(releases.baselineOnFirstRun, DEFAULT_SETTINGS.releases.baselineOnFirstRun),
221
232
  perRepoLimit: Math.round(num(releases.perRepoLimit, DEFAULT_SETTINGS.releases.perRepoLimit)),
222
233
  maxNotesChars: Math.round(num(releases.maxNotesChars, DEFAULT_SETTINGS.releases.maxNotesChars)),
223
- period: isPeriod(releasesPeriod) ? releasesPeriod : DEFAULT_SETTINGS.releases.period
234
+ summaryMaxChars: Math.round(num(releases.summaryMaxChars, DEFAULT_SETTINGS.releases.summaryMaxChars)),
235
+ docMaxLines: Math.round(num(releases.docMaxLines, DEFAULT_SETTINGS.releases.docMaxLines)),
236
+ concurrency: autoNumber(releases.concurrency, DEFAULT_SETTINGS.releases.concurrency),
237
+ budgetSeconds: autoNumber(releases.budgetSeconds, DEFAULT_SETTINGS.releases.budgetSeconds),
238
+ period: isPeriod(releasesPeriod) ? releasesPeriod : DEFAULT_SETTINGS.releases.period,
239
+ schedule: {
240
+ enabled: bool(releasesSchedule.enabled, DEFAULT_SETTINGS.releases.schedule.enabled),
241
+ weekday: Math.round(num(releasesSchedule.weekday, DEFAULT_SETTINGS.releases.schedule.weekday)),
242
+ time: str(releasesSchedule.time, DEFAULT_SETTINGS.releases.schedule.time),
243
+ timezone: str(releasesSchedule.timezone, DEFAULT_SETTINGS.releases.schedule.timezone)
244
+ }
224
245
  },
225
246
  output: {
226
247
  dir: str(output.dir, DEFAULT_SETTINGS.output.dir).trim() || DEFAULT_SETTINGS.output.dir
@@ -245,6 +266,10 @@ function isValidTimezone(timezone) {
245
266
  return false;
246
267
  }
247
268
  }
269
+ function autoInRange(value, min, max) {
270
+ if (value === "auto") return true;
271
+ return Number.isInteger(value) && value >= min && value <= max;
272
+ }
248
273
  function validateSettings(settings, providers = CORE_PROVIDERS) {
249
274
  const problems = [];
250
275
  const { model, repo, trending, summarize, releases, output, schedule } = settings;
@@ -298,8 +323,12 @@ function validateSettings(settings, providers = CORE_PROVIDERS) {
298
323
  if (!Number.isInteger(summarize.readmeChars) || summarize.readmeChars <= 0) {
299
324
  problems.push({ field: "summarize.readmeChars", code: "CONFIG_INVALID", message: "readmeChars \u5FC5\u987B\u662F\u6B63\u6574\u6570" });
300
325
  }
301
- if (!isPeriod(releases.period)) {
302
- problems.push({ field: "releases.period", code: "CONFIG_INVALID", message: "releases.period \u5FC5\u987B\u662F daily / weekly / monthly" });
326
+ if (releases.period !== "weekly") {
327
+ problems.push({
328
+ field: "releases.period",
329
+ code: "CONFIG_INVALID",
330
+ message: "\u672C\u7248\u672C\u53EA\u652F\u6301\u6BCF\u5468\uFF08weekly\uFF09\u53D1\u7248\u6458\u8981\uFF0C\u8BF7\u628A releases.period \u6539\u4E3A weekly"
331
+ });
303
332
  }
304
333
  if (!Number.isInteger(releases.perRepoLimit) || releases.perRepoLimit < 1 || releases.perRepoLimit > 20) {
305
334
  problems.push({ field: "releases.perRepoLimit", code: "CONFIG_INVALID", message: "perRepoLimit \u5FC5\u987B\u662F 1\u201320 \u7684\u6574\u6570" });
@@ -307,6 +336,27 @@ function validateSettings(settings, providers = CORE_PROVIDERS) {
307
336
  if (!Number.isInteger(releases.maxNotesChars) || releases.maxNotesChars < 500 || releases.maxNotesChars > 2e4) {
308
337
  problems.push({ field: "releases.maxNotesChars", code: "CONFIG_INVALID", message: "maxNotesChars \u5FC5\u987B\u662F 500\u201320000 \u7684\u6574\u6570" });
309
338
  }
339
+ if (!Number.isInteger(releases.summaryMaxChars) || releases.summaryMaxChars < 500 || releases.summaryMaxChars > 2e3) {
340
+ problems.push({ field: "releases.summaryMaxChars", code: "CONFIG_INVALID", message: "summaryMaxChars \u5FC5\u987B\u662F 500\u20132000 \u7684\u6574\u6570" });
341
+ }
342
+ if (!Number.isInteger(releases.docMaxLines) || releases.docMaxLines < 100 || releases.docMaxLines > 2e3) {
343
+ problems.push({ field: "releases.docMaxLines", code: "CONFIG_INVALID", message: "docMaxLines \u5FC5\u987B\u662F 100\u20132000 \u7684\u6574\u6570" });
344
+ }
345
+ if (!autoInRange(releases.concurrency, 1, 8)) {
346
+ problems.push({ field: "releases.concurrency", code: "CONFIG_INVALID", message: "concurrency \u5FC5\u987B\u662F auto \u6216 1\u20138 \u7684\u6574\u6570" });
347
+ }
348
+ if (!autoInRange(releases.budgetSeconds, 1, 300)) {
349
+ problems.push({ field: "releases.budgetSeconds", code: "CONFIG_INVALID", message: "budgetSeconds \u5FC5\u987B\u662F auto \u6216 1\u2013300 \u7684\u6574\u6570" });
350
+ }
351
+ if (!Number.isInteger(releases.schedule.weekday) || releases.schedule.weekday < 1 || releases.schedule.weekday > 7) {
352
+ problems.push({ field: "releases.schedule.weekday", code: "CONFIG_INVALID", message: "\u53D1\u7248\u65E5\u5FC5\u987B\u662F ISO \u661F\u671F 1\u20137\uFF081=\u5468\u4E00\uFF09" });
353
+ }
354
+ if (!TIME_RE.test(releases.schedule.time)) {
355
+ problems.push({ field: "releases.schedule.time", code: "CONFIG_INVALID", message: "\u65F6\u95F4\u683C\u5F0F\u5FC5\u987B\u662F HH:MM \u6216 HH:MM:SS" });
356
+ }
357
+ if (!isValidTimezone(releases.schedule.timezone)) {
358
+ problems.push({ field: "releases.schedule.timezone", code: "CONFIG_INVALID", message: "\u65F6\u533A\u5FC5\u987B\u662F IANA \u540D\u79F0\uFF08\u5982 Asia/Shanghai\uFF09" });
359
+ }
310
360
  if (output.dir.trim() === "" || path.isAbsolute(output.dir) || output.dir.split(/[\\/]/).includes("..")) {
311
361
  problems.push({ field: "output.dir", code: "CONFIG_INVALID", message: "\u8F93\u51FA\u76EE\u5F55\u5FC5\u987B\u662F\u4ED3\u5E93\u5185\u7684\u76F8\u5BF9\u8DEF\u5F84\uFF0C\u4E14\u4E0D\u542B .." });
312
362
  }
@@ -321,7 +371,11 @@ function validateSettings(settings, providers = CORE_PROVIDERS) {
321
371
  function effectiveBaseURL(model) {
322
372
  return model.baseURL !== "" ? model.baseURL : PROVIDER_BASE_URLS[model.provider] ?? "";
323
373
  }
324
- function toCoreConfig(settings, apiKey, baseURLOverride, period) {
374
+ function toCoreConfig(settings, apiKey, baseURLOverride, period, repoCount = 0) {
375
+ const releases = settings.releases;
376
+ const repoTotal = Number.isFinite(repoCount) && repoCount > 0 ? Math.floor(repoCount) : 0;
377
+ const concurrency = releases.concurrency === "auto" ? Math.min(8, Math.max(4, Math.ceil(repoTotal / 10))) : releases.concurrency;
378
+ const budgetSeconds = releases.budgetSeconds === "auto" ? Math.min(300, 30 + 3 * repoTotal) : releases.budgetSeconds;
325
379
  const config = {
326
380
  model: {
327
381
  provider: settings.model.provider,
@@ -363,13 +417,21 @@ function toCoreConfig(settings, apiKey, baseURLOverride, period) {
363
417
  releases: {
364
418
  // 发版订阅常开:插件侧不再提供开关,是否产出由订阅列表决定。
365
419
  enabled: true,
366
- includePrerelease: settings.releases.includePrerelease,
367
- includeDrafts: settings.releases.includeDrafts,
368
- fallbackToTags: settings.releases.fallbackToTags,
369
- baselineOnFirstRun: settings.releases.baselineOnFirstRun,
370
- perRepoLimit: settings.releases.perRepoLimit,
371
- maxNotesChars: settings.releases.maxNotesChars,
372
- period: settings.releases.period
420
+ includePrerelease: releases.includePrerelease,
421
+ includeDrafts: releases.includeDrafts,
422
+ fallbackToTags: releases.fallbackToTags,
423
+ baselineOnFirstRun: releases.baselineOnFirstRun,
424
+ perRepoLimit: releases.perRepoLimit,
425
+ maxNotesChars: releases.maxNotesChars,
426
+ summaryMaxChars: releases.summaryMaxChars,
427
+ docMaxLines: releases.docMaxLines,
428
+ // 核心按这个时区切 ISO 周;必须与插件侧 lastReleaseWeek 的去重口径一致。
429
+ timezone: releases.schedule.timezone,
430
+ weekStart: "monday",
431
+ concurrency,
432
+ budgetSeconds,
433
+ // 核心只实现每周一次;非法值在校验阶段已拦下,这里不再透传枚举。
434
+ period: "weekly"
373
435
  },
374
436
  output: {
375
437
  dir: settings.output.dir,
@@ -448,6 +510,7 @@ __export(state_exports, {
448
510
  HISTORY_LIMIT: () => HISTORY_LIMIT,
449
511
  PREVIEW_LIMIT: () => PREVIEW_LIMIT,
450
512
  PREVIEW_MAX_BYTES: () => PREVIEW_MAX_BYTES,
513
+ RELEASE_RUN_LIMIT: () => RELEASE_RUN_LIMIT,
451
514
  Store: () => Store,
452
515
  newRunId: () => newRunId,
453
516
  sumTokens: () => sumTokens,
@@ -460,6 +523,7 @@ var PREVIEW_LIMIT = 90;
460
523
  var PREVIEW_MAX_BYTES = 512 * 1024;
461
524
  var CONTENT_HASH_ALGO_V2 = "jcs-sha256-v1";
462
525
  var V2_CORE_MIN = [0, 3, 0];
526
+ var RELEASE_RUN_LIMIT = 30;
463
527
  function readJson(file, fallback) {
464
528
  try {
465
529
  const text = readFileSync(file, "utf8");
@@ -488,6 +552,11 @@ function coreVersionIsV2(coreVersion) {
488
552
  function digestIsV2(digest) {
489
553
  return digest !== void 0 && digest.contentHashAlgo === CONTENT_HASH_ALGO_V2 && coreVersionIsV2(digest.coreVersion);
490
554
  }
555
+ function isReleaseRunRecord(value) {
556
+ if (typeof value !== "object" || value === null) return false;
557
+ const record = value;
558
+ return typeof record.id === "string" && typeof record.startedAt === "string" && typeof record.finishedAt === "string" && (record.status === "ok" || record.status === "failed" || record.status === "skipped" || record.status === "cancelled");
559
+ }
491
560
  function normalizeState(state) {
492
561
  const next = { ...state };
493
562
  if (next.lastDigest !== void 0 && !digestIsV2(next.lastDigest)) {
@@ -582,6 +651,21 @@ var Store = class {
582
651
  const state = this.readState();
583
652
  this.writeState({ ...state, ...next, lastResult: record });
584
653
  }
654
+ /**
655
+ * 记录一次发版运行:只写 `state.json`(不发版记录进 `history.json`,两者节奏不同)。
656
+ * 记录裁剪到 RELEASE_RUN_LIMIT 条,`next` 用来顺带更新 lastReleaseAt/lastReleaseWeek。
657
+ */
658
+ appendReleaseRun(record, next = {}) {
659
+ const state = this.readState();
660
+ const runs = [record, ...Array.isArray(state.releaseRuns) ? state.releaseRuns : []].slice(0, RELEASE_RUN_LIMIT);
661
+ this.writeState({ ...state, ...next, releaseRuns: runs });
662
+ }
663
+ /** 读发版运行记录;缺失/损坏(不是数组或行不是对象)一律按空数组处理,不让界面崩。 */
664
+ readReleaseRuns() {
665
+ const state = this.readState();
666
+ if (!Array.isArray(state.releaseRuns)) return [];
667
+ return state.releaseRuns.filter(isReleaseRunRecord);
668
+ }
585
669
  newLogPath(id) {
586
670
  ensureDir(this.logsPath);
587
671
  return path3.join(this.logsPath, `${id}.log`);
@@ -911,6 +995,8 @@ __export(ipc_exports, {
911
995
  IpcError: () => IpcError,
912
996
  SUPPORTED_CONTRACT_MAJOR: () => SUPPORTED_CONTRACT_MAJOR,
913
997
  SUPPORTED_DIGEST_SCHEMA: () => SUPPORTED_DIGEST_SCHEMA,
998
+ isSubscriptionDoc: () => isSubscriptionDoc,
999
+ isSubscriptionStatus: () => isSubscriptionStatus,
914
1000
  isVersionInfo: () => isVersionInfo,
915
1001
  parseJsonObject: () => parseJsonObject,
916
1002
  parseRunResult: () => parseRunResult,
@@ -921,6 +1007,17 @@ import { validators } from "@morehao/trendradar-contract";
921
1007
  var IPC_SCHEMA_VERSION = 2;
922
1008
  var SUPPORTED_DIGEST_SCHEMA = 2;
923
1009
  var SUPPORTED_CONTRACT_MAJOR = 0;
1010
+ var SUBSCRIPTION_STATUSES = ["baseline", "ok", "no_change", "rate_limited", "failed"];
1011
+ function isSubscriptionStatus(value) {
1012
+ return typeof value === "string" && SUBSCRIPTION_STATUSES.includes(value);
1013
+ }
1014
+ function isSubscriptionDoc(value) {
1015
+ if (!isRecord(value)) return false;
1016
+ return typeof value.fullName === "string" && value.fullName !== "" && typeof value.volume === "number" && Number.isInteger(value.volume) && value.volume >= 1 && typeof value.markdown === "string";
1017
+ }
1018
+ function isSubscriptionItem(value) {
1019
+ return isRecord(value) && typeof value.fullName === "string" && value.fullName !== "" && isSubscriptionStatus(value.status);
1020
+ }
924
1021
  var IpcError = class extends Error {
925
1022
  code;
926
1023
  constructor(code, message) {
@@ -996,6 +1093,16 @@ function timing(options) {
996
1093
  return { startedAt: options.startedAt, finishedAt: options.finishedAt, durationMs: options.durationMs };
997
1094
  }
998
1095
  var REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
1096
+ function parseSubscriptionList(value) {
1097
+ if (!isRecord(value)) throw new IpcError("IPC_INVALID", "\u6838\u5FC3 subscribe \u8FD4\u56DE\u7684 subscriptions \u4E0D\u662F\u5BF9\u8C61");
1098
+ const rawRepos = value.repos;
1099
+ if (!Array.isArray(rawRepos) || rawRepos.some((item) => typeof item !== "string")) {
1100
+ throw new IpcError("IPC_INVALID", "\u6838\u5FC3 subscribe \u8FD4\u56DE\u7684 subscriptions.repos \u4E0D\u662F\u5B57\u7B26\u4E32\u6570\u7EC4");
1101
+ }
1102
+ const repos = rawRepos.filter((item) => typeof item === "string");
1103
+ const items = Array.isArray(value.items) ? value.items.filter(isSubscriptionItem) : void 0;
1104
+ return { repos, ...items === void 0 ? {} : { items } };
1105
+ }
999
1106
  function parseSubscribeResult(text) {
1000
1107
  const trimmed = text.trim();
1001
1108
  if (trimmed === "") throw new IpcError("IPC_INVALID", "\u6838\u5FC3 subscribe \u8F93\u51FA\u4E3A\u7A7A");
@@ -1012,15 +1119,24 @@ function parseSubscribeResult(text) {
1012
1119
  throw new IpcError("IPC_INVALID", "\u6838\u5FC3 subscribe --json \u4E0D\u662F\u5951\u7EA6\u4FE1\u5C01\uFF08ipcSchema=2 / ok\uFF09");
1013
1120
  }
1014
1121
  const error = normalizeError(envelope.error);
1015
- const repos = [];
1016
- for (const line of lines.slice(0, -1)) {
1017
- const value = line.trim();
1018
- if (value === "") continue;
1019
- if (REPO_PATTERN.test(value)) repos.push(value);
1122
+ const list = envelope.subscriptions === void 0 ? void 0 : parseSubscriptionList(envelope.subscriptions);
1123
+ const doc = envelope.subscriptionDoc;
1124
+ if (doc !== void 0 && !isSubscriptionDoc(doc)) {
1125
+ throw new IpcError("IPC_INVALID", "\u6838\u5FC3 subscribe \u8FD4\u56DE\u7684 subscriptionDoc \u4E0D\u662F\u5951\u7EA6 SubscriptionDoc\uFF08fullName/volume/markdown\uFF09");
1126
+ }
1127
+ const repos = list?.repos ?? [];
1128
+ if (list === void 0) {
1129
+ for (const line of lines.slice(0, -1)) {
1130
+ const value = line.trim();
1131
+ if (value === "") continue;
1132
+ if (REPO_PATTERN.test(value)) repos.push(value);
1133
+ }
1020
1134
  }
1021
1135
  return {
1022
1136
  ok: envelope.ok === true,
1023
1137
  repos,
1138
+ ...list?.items === void 0 ? {} : { items: list.items },
1139
+ ...doc === void 0 ? {} : { doc },
1024
1140
  ...error === void 0 ? {} : { error }
1025
1141
  };
1026
1142
  }
@@ -1494,6 +1610,7 @@ function buildCoreArgs(channelPath, options) {
1494
1610
  if (options.dryRun) args.push("--dry-run");
1495
1611
  if (options.date !== void 0 && options.date !== "") args.push("--date", options.date);
1496
1612
  if (options.period !== void 0) args.push("--period", options.period);
1613
+ if (options.stages !== void 0 && options.stages.length > 0) args.push("--stages", options.stages.join(","));
1497
1614
  return args;
1498
1615
  }
1499
1616
  async function runCore(options) {
@@ -1501,10 +1618,16 @@ async function runCore(options) {
1501
1618
  const startedAt = nowIso();
1502
1619
  const started = Date.now();
1503
1620
  const channel = await openConfigChannel();
1504
- const yaml = `${toYaml(toCoreConfig(options.settings, options.apiKey, options.baseURLOverride, options.period))}
1621
+ const yaml = `${toYaml(toCoreConfig(options.settings, options.apiKey, options.baseURLOverride, options.period, options.repoCount))}
1505
1622
  `;
1506
1623
  const secrets = [options.apiKey, options.token, ...options.secrets ?? []];
1507
- const child = spawn2(options.core.path, buildCoreArgs(channel.path, { dryRun: options.dryRun, date: options.date, period: options.period }), {
1624
+ const args = buildCoreArgs(channel.path, {
1625
+ dryRun: options.dryRun,
1626
+ date: options.date,
1627
+ period: options.period,
1628
+ stages: options.stages
1629
+ });
1630
+ const child = spawn2(options.core.path, args, {
1508
1631
  env: sanitizedEnv(process.env, {
1509
1632
  GITHUB_TOKEN: options.token,
1510
1633
  GH_TOKEN: options.token,
@@ -2154,18 +2277,23 @@ function authorized(request, token) {
2154
2277
  // src/host/schedule.ts
2155
2278
  var schedule_exports = {};
2156
2279
  __export(schedule_exports, {
2280
+ RELEASE_CATCH_UP_MINUTES: () => RELEASE_CATCH_UP_MINUTES,
2157
2281
  Scheduler: () => Scheduler,
2158
2282
  daysInMonth: () => daysInMonth,
2159
2283
  isDue: () => isDue,
2160
2284
  isPeriodTriggerDay: () => isPeriodTriggerDay,
2285
+ isReleaseDue: () => isReleaseDue,
2286
+ isoWeekKey: () => isoWeekKey,
2161
2287
  nextOccurrence: () => nextOccurrence,
2162
2288
  nextOccurrenceFor: () => nextOccurrenceFor,
2289
+ nextReleaseOccurrence: () => nextReleaseOccurrence,
2163
2290
  parseClock: () => parseClock,
2164
2291
  zonedDateString: () => zonedDateString,
2165
2292
  zonedTimeToUtc: () => zonedTimeToUtc
2166
2293
  });
2167
2294
  var MINUTE = 6e4;
2168
2295
  var CATCH_UP_MINUTES = 6 * 60;
2296
+ var RELEASE_CATCH_UP_MINUTES = 6 * 60;
2169
2297
  var MAX_ATTEMPTS_PER_DAY = 2;
2170
2298
  var RETRY_GAP_MS = 5 * MINUTE;
2171
2299
  function partsOf(date, timezone) {
@@ -2239,6 +2367,50 @@ function isDue(now, schedule) {
2239
2367
  const late = nowMinutes - targetMinutes;
2240
2368
  return late >= 0 && late <= CATCH_UP_MINUTES;
2241
2369
  }
2370
+ function isoWeekOf(year, month, day) {
2371
+ const d = new Date(Date.UTC(year, month - 1, day));
2372
+ const dayIdx = (d.getUTCDay() + 6) % 7;
2373
+ d.setUTCDate(d.getUTCDate() - dayIdx + 3);
2374
+ const isoYear = d.getUTCFullYear();
2375
+ const firstThursday = new Date(Date.UTC(isoYear, 0, 4));
2376
+ const firstIdx = (firstThursday.getUTCDay() + 6) % 7;
2377
+ firstThursday.setUTCDate(firstThursday.getUTCDate() - firstIdx + 3);
2378
+ const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 864e5));
2379
+ return `${isoYear}-W${String(week).padStart(2, "0")}`;
2380
+ }
2381
+ function isoWeekKey(date, timezone) {
2382
+ const parts = partsOf(date, timezone);
2383
+ return isoWeekOf(parts.year, parts.month, parts.day);
2384
+ }
2385
+ function isoWeekdayOf(date, timezone) {
2386
+ const parts = partsOf(date, timezone);
2387
+ const day = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay();
2388
+ return day === 0 ? 7 : day;
2389
+ }
2390
+ function nextReleaseOccurrence(now, cfg) {
2391
+ const { hour, minute, second } = parseClock(cfg.time);
2392
+ const today = partsOf(now, cfg.timezone);
2393
+ const start = Date.UTC(today.year, today.month - 1, today.day);
2394
+ for (let index = 0; index <= 15; index += 1) {
2395
+ const d = new Date(start + index * 864e5);
2396
+ const year = d.getUTCFullYear();
2397
+ const month = d.getUTCMonth() + 1;
2398
+ const day = d.getUTCDate();
2399
+ const dayOfWeek = d.getUTCDay();
2400
+ const weekday = dayOfWeek === 0 ? 7 : dayOfWeek;
2401
+ if (weekday !== cfg.weekday) continue;
2402
+ const candidate = zonedTimeToUtc(year, month, day, hour, minute, second, cfg.timezone);
2403
+ if (candidate.getTime() > now.getTime()) return candidate;
2404
+ }
2405
+ throw new Error(`\u65E0\u6CD5\u8BA1\u7B97\u4E0B\u4E00\u6B21\u53D1\u7248\u8FD0\u884C\u65F6\u523B\uFF08weekday=${String(cfg.weekday)}\uFF09`);
2406
+ }
2407
+ function isReleaseDue(now, cfg) {
2408
+ if (isoWeekdayOf(now, cfg.timezone) !== cfg.weekday) return false;
2409
+ const { hour, minute } = parseClock(cfg.time);
2410
+ const parts = partsOf(now, cfg.timezone);
2411
+ const late = parts.hour * 60 + parts.minute - (hour * 60 + minute);
2412
+ return late >= 0 && late <= RELEASE_CATCH_UP_MINUTES;
2413
+ }
2242
2414
  function daysInMonth(year, month) {
2243
2415
  return new Date(Date.UTC(year, month, 0)).getUTCDate();
2244
2416
  }
@@ -2274,6 +2446,8 @@ var Scheduler = class {
2274
2446
  stopped = false;
2275
2447
  /** 当天已尝试次数与时间;只在内存里,进程重启后重新计数。 */
2276
2448
  attempts = /* @__PURE__ */ new Map();
2449
+ /** 发版行程的尝试计数(键 = ISO 周 + 本地日期);理由同榜单:失败不能每分钟重试。 */
2450
+ releaseAttempts = /* @__PURE__ */ new Map();
2277
2451
  constructor(deps) {
2278
2452
  this.deps = deps;
2279
2453
  }
@@ -2299,9 +2473,24 @@ var Scheduler = class {
2299
2473
  else if (typeof handle === "number" || handle !== void 0) clearInterval(handle);
2300
2474
  this.timerHandle = void 0;
2301
2475
  }
2302
- /** 公开给测试:判断此刻是否该跑,并按需触发一次。 */
2476
+ /**
2477
+ * 公开给测试:判断此刻是否该跑,并按需触发。
2478
+ *
2479
+ * 每个 tick 都**同时**评估榜单与发版两条行程(interval 已经是 60 s)。返回值合并规则
2480
+ * 保证老调用方不受影响:真的跑了 → `ran`;否则有人占着锁 → `busy`;再否则只要有
2481
+ * 一条行程是禁用状态就沿用旧的 `disabled`;都没有 → `skipped`。
2482
+ */
2303
2483
  async tick(now = /* @__PURE__ */ new Date()) {
2304
2484
  if (this.stopped) return "skipped";
2485
+ const digest = await this.tickDigest(now);
2486
+ const releases = await this.tickReleases(now);
2487
+ if (digest === "ran" || releases === "ran") return "ran";
2488
+ if (digest === "busy" || releases === "busy") return "busy";
2489
+ if (digest === "disabled" || releases === "disabled") return "disabled";
2490
+ return "skipped";
2491
+ }
2492
+ /** 榜单行程(原 tick 的逻辑,不改)。 */
2493
+ async tickDigest(now) {
2305
2494
  const schedule = this.readSchedule();
2306
2495
  if (!schedule.enabled) return "disabled";
2307
2496
  if (!isDue(now, schedule)) return "skipped";
@@ -2332,6 +2521,47 @@ var Scheduler = class {
2332
2521
  }
2333
2522
  return ran ? "ran" : "skipped";
2334
2523
  }
2524
+ /**
2525
+ * 发版行程:每周一次、只跑 `--stages releases`。
2526
+ *
2527
+ * 跳过条件(全部满足才跑):开启、星期/时间命中且迟到 ≤ 6 h、本周尚未成功过
2528
+ * (`state.lastReleaseWeek`)、当前没有别的运行占锁。
2529
+ * 失败**不锁本周**,但也只有 2 次机会(间隔 5 min)——否则一个坏 token 会在 6 h 的
2530
+ * 补跑窗口里每分钟 spawn 一次核心、白白打 GitHub API。
2531
+ */
2532
+ async tickReleases(now) {
2533
+ const cfg = this.readReleaseSchedule();
2534
+ if (!cfg.enabled) return "disabled";
2535
+ if (!isReleaseDue(now, cfg)) return "skipped";
2536
+ const week = (() => {
2537
+ try {
2538
+ return isoWeekKey(now, cfg.timezone);
2539
+ } catch {
2540
+ return void 0;
2541
+ }
2542
+ })();
2543
+ if (week === void 0) return "skipped";
2544
+ const state = this.deps.store.readState();
2545
+ if (state.lastReleaseWeek === week) return "skipped";
2546
+ const key = `${week}:${zonedDateString(now, cfg.timezone)}`;
2547
+ for (const existing of this.releaseAttempts.keys()) {
2548
+ if (!existing.startsWith(`${week}:`)) this.releaseAttempts.delete(existing);
2549
+ }
2550
+ const attempt = this.releaseAttempts.get(key);
2551
+ if (attempt !== void 0) {
2552
+ if (attempt.count >= MAX_ATTEMPTS_PER_DAY) return "skipped";
2553
+ if (now.getTime() - attempt.lastAt < RETRY_GAP_MS) return "skipped";
2554
+ }
2555
+ if (this.deps.isRunning()) return "busy";
2556
+ this.releaseAttempts.set(key, { count: (attempt?.count ?? 0) + 1, lastAt: now.getTime() });
2557
+ try {
2558
+ await this.deps.runReleases("schedule");
2559
+ return "ran";
2560
+ } catch (error) {
2561
+ this.deps.onError?.(error);
2562
+ return "skipped";
2563
+ }
2564
+ }
2335
2565
  /** 该周期今天是否已经成功过(含无变更跳过)。 */
2336
2566
  succeededToday(state, period, today, timezone) {
2337
2567
  const stamp = state.periodSuccess?.[period];
@@ -2350,6 +2580,16 @@ var Scheduler = class {
2350
2580
  periods: settings.schedule.periods
2351
2581
  };
2352
2582
  }
2583
+ /** 发版行程的节奏来自 `settings.releases.schedule`(与榜单 `schedule.*` 相互独立)。 */
2584
+ readReleaseSchedule() {
2585
+ const { schedule } = this.deps.store.readSettings().releases;
2586
+ return {
2587
+ enabled: schedule.enabled,
2588
+ weekday: schedule.weekday,
2589
+ time: schedule.time,
2590
+ timezone: schedule.timezone
2591
+ };
2592
+ }
2353
2593
  /** 下一次运行(取所有已启用周期里最近的一次;禁用时为 undefined)。 */
2354
2594
  next() {
2355
2595
  const schedule = this.readSchedule();
@@ -2367,9 +2607,21 @@ var Scheduler = class {
2367
2607
  return void 0;
2368
2608
  }
2369
2609
  }
2610
+ /** 下一次发版运行(禁用/非法时为 undefined)。 */
2611
+ nextRelease(now = /* @__PURE__ */ new Date()) {
2612
+ const cfg = this.readReleaseSchedule();
2613
+ if (!cfg.enabled) return void 0;
2614
+ try {
2615
+ return nextReleaseOccurrence(now, cfg);
2616
+ } catch {
2617
+ return void 0;
2618
+ }
2619
+ }
2370
2620
  describe(state, now = /* @__PURE__ */ new Date()) {
2371
2621
  const schedule = this.readSchedule();
2622
+ const release = this.readReleaseSchedule();
2372
2623
  const next = schedule.enabled ? this.next() : void 0;
2624
+ const nextRelease = release.enabled ? this.nextRelease(now) : void 0;
2373
2625
  return {
2374
2626
  enabled: schedule.enabled,
2375
2627
  time: schedule.time,
@@ -2378,11 +2630,98 @@ var Scheduler = class {
2378
2630
  ...next === void 0 ? {} : { nextRunAt: next.toISOString() },
2379
2631
  ...state.lastRunAt === void 0 ? {} : { lastRunDate: zonedDateString(new Date(state.lastRunAt), schedule.timezone) },
2380
2632
  catchUpMinutes: CATCH_UP_MINUTES,
2381
- source: "timer"
2633
+ source: "timer",
2634
+ // 发版行程的信息并列给出,界面/工具不必再去读设置才知道下一次何时跑。
2635
+ releaseEnabled: release.enabled,
2636
+ releaseWeekday: release.weekday,
2637
+ releaseTime: release.time,
2638
+ releaseTimezone: release.timezone,
2639
+ ...nextRelease === void 0 ? {} : { nextReleaseAt: nextRelease.toISOString() },
2640
+ ...state.lastReleaseAt === void 0 ? {} : { lastReleaseAt: state.lastReleaseAt },
2641
+ ...state.lastReleaseWeek === void 0 ? {} : { lastReleaseWeek: state.lastReleaseWeek },
2642
+ releaseCatchUpMinutes: RELEASE_CATCH_UP_MINUTES
2382
2643
  };
2383
2644
  }
2384
2645
  };
2385
2646
 
2647
+ // src/host/subscribe.ts
2648
+ var subscribe_exports = {};
2649
+ __export(subscribe_exports, {
2650
+ RELEASES_TIMEOUT_MS: () => RELEASES_TIMEOUT_MS,
2651
+ SUBSCRIBE_TIMEOUT_MS: () => SUBSCRIBE_TIMEOUT_MS,
2652
+ isValidRepo: () => isValidRepo,
2653
+ runSubscribe: () => runSubscribe
2654
+ });
2655
+ import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync2, rmSync as rmSync2 } from "fs";
2656
+ import os3 from "os";
2657
+ import path8 from "path";
2658
+ var SUBSCRIBE_TIMEOUT_MS = 8e3;
2659
+ var RELEASES_TIMEOUT_MS = 3e4;
2660
+ var REPO_PATTERN2 = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
2661
+ function isValidRepo(repo) {
2662
+ return REPO_PATTERN2.test(repo.trim());
2663
+ }
2664
+ var SUBSCRIBE_PLACEHOLDER_BASE_URL = "http://127.0.0.1:9/v1";
2665
+ function writeTempConfig(settings) {
2666
+ const dir = mkdtempSync2(path8.join(os3.tmpdir(), "dsh-subscribe-"));
2667
+ const file = path8.join(dir, "config.yml");
2668
+ const placeholder = effectiveBaseURL(settings.model) === "" ? SUBSCRIBE_PLACEHOLDER_BASE_URL : void 0;
2669
+ const yaml = `${toYaml(toCoreConfig(settings, "", placeholder))}
2670
+ `;
2671
+ writeFileSync2(file, yaml, { mode: 384 });
2672
+ return { file, dir };
2673
+ }
2674
+ function requireUsableConfig(settings) {
2675
+ const problems = validateSettings(settings);
2676
+ if (problems.length > 0) {
2677
+ const first = problems[0];
2678
+ throw new IpcError("CONFIG_INVALID", `\u914D\u7F6E\u4E0D\u5B8C\u6574\uFF0C\u65E0\u6CD5\u7BA1\u7406\u8BA2\u9605\uFF1A${first?.message ?? "\u672A\u77E5\u95EE\u9898"}`);
2679
+ }
2680
+ }
2681
+ async function runSubscribe(input, deps) {
2682
+ const env = deps.env ?? process.env;
2683
+ const settings = deps.store.readSettings();
2684
+ requireUsableConfig(settings);
2685
+ if (input.action !== "list") {
2686
+ const repo = (input.repo ?? "").trim();
2687
+ if (!isValidRepo(repo)) {
2688
+ throw new IpcError("CONFIG_INVALID", `\u4ED3\u5E93\u5FC5\u987B\u662F owner/repo \u5F62\u6001\uFF1A${repo || "(\u7A7A)"}`);
2689
+ }
2690
+ }
2691
+ if (input.volume !== void 0 && (!Number.isInteger(input.volume) || input.volume < 1)) {
2692
+ throw new IpcError("CONFIG_INVALID", `\u5377\u53F7\u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF1A${String(input.volume)}`);
2693
+ }
2694
+ const corePath = deps.corePath !== void 0 ? deps.corePath : (await locateCore({ settings, env, allowDownload: settings.coreAutoDownload })).path;
2695
+ const { file, dir } = writeTempConfig(settings);
2696
+ try {
2697
+ const request = {
2698
+ ipcSchema: 2,
2699
+ command: "subscribe",
2700
+ action: input.action,
2701
+ ...input.action === "list" ? {} : { repo: (input.repo ?? "").trim() },
2702
+ // 仅 `releases` 认卷号;核心对其它 action 传 volume 会直接判 CONFIG_INVALID。
2703
+ ...input.action === "releases" && input.volume !== void 0 ? { volume: input.volume } : {},
2704
+ configPath: file
2705
+ };
2706
+ const timeoutMs = input.action === "releases" ? RELEASES_TIMEOUT_MS : SUBSCRIBE_TIMEOUT_MS;
2707
+ const result = await execCapture(corePath, ["subscribe", "--json"], {
2708
+ env: sanitizedEnv(env),
2709
+ input: JSON.stringify(request),
2710
+ timeoutMs
2711
+ });
2712
+ if (result.timedOut) {
2713
+ throw new IpcError("SUBSCRIBE_FAILED", `\u8BA2\u9605\u547D\u4EE4\u8D85\u65F6\uFF08${Math.round(timeoutMs / 1e3)} s\uFF09`);
2714
+ }
2715
+ if (result.stdout.trim() === "") {
2716
+ const detail = result.stderr.trim();
2717
+ throw new IpcError("SUBSCRIBE_FAILED", `\u8BA2\u9605\u547D\u4EE4\u65E0\u8F93\u51FA${detail === "" ? "" : `\uFF1A${detail}`}`);
2718
+ }
2719
+ return parseSubscribeResult(result.stdout);
2720
+ } finally {
2721
+ rmSync2(dir, { recursive: true, force: true });
2722
+ }
2723
+ }
2724
+
2386
2725
  // src/host/runner.ts
2387
2726
  var runner_exports = {};
2388
2727
  __export(runner_exports, {
@@ -2393,7 +2732,7 @@ __export(runner_exports, {
2393
2732
  });
2394
2733
  import { createHash as createHash2 } from "crypto";
2395
2734
  import { existsSync as existsSync4, readFileSync as readFileSync3, realpathSync, statSync as statSync5 } from "fs";
2396
- import path8 from "path";
2735
+ import path9 from "path";
2397
2736
  function repoProblem(repoPath) {
2398
2737
  if (repoPath.trim() === "") return { code: "CONFIG_INVALID", message: "\u8FD8\u6CA1\u6709\u9009\u62E9\u5F52\u6863\u4ED3\u5E93" };
2399
2738
  if (!existsSync4(repoPath)) return { code: "REPO_NOT_GIT", message: `\u76EE\u5F55\u4E0D\u5B58\u5728\uFF1A${repoPath}` };
@@ -2402,7 +2741,7 @@ function repoProblem(repoPath) {
2402
2741
  } catch {
2403
2742
  return { code: "REPO_NOT_GIT", message: `\u65E0\u6CD5\u8BBF\u95EE\uFF1A${repoPath}` };
2404
2743
  }
2405
- if (!existsSync4(path8.join(repoPath, ".git"))) {
2744
+ if (!existsSync4(path9.join(repoPath, ".git"))) {
2406
2745
  return { code: "REPO_NOT_GIT", message: `\u4E0D\u662F git \u4ED3\u5E93\uFF1A${repoPath}\uFF08\u63D2\u4EF6\u4E0D\u4F1A\u66FF\u4F60 git init\uFF09` };
2407
2746
  }
2408
2747
  return void 0;
@@ -2415,27 +2754,23 @@ function runDate(settings, now = /* @__PURE__ */ new Date()) {
2415
2754
  }
2416
2755
  }
2417
2756
  function isInside(root, target) {
2418
- const rel = path8.relative(root, target);
2419
- return rel !== "" && !rel.startsWith("..") && !path8.isAbsolute(rel);
2757
+ const rel = path9.relative(root, target);
2758
+ return rel !== "" && !rel.startsWith("..") && !path9.isAbsolute(rel);
2420
2759
  }
2421
2760
  function normalizeSha256(value) {
2422
2761
  if (typeof value !== "string") return void 0;
2423
2762
  const hex = value.trim().replace(/^sha256:/i, "").toLowerCase();
2424
2763
  return /^[0-9a-f]{64}$/.test(hex) ? hex : void 0;
2425
2764
  }
2765
+ function counterOf(value) {
2766
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
2767
+ }
2426
2768
  function isoWeekName(date) {
2427
2769
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
2428
2770
  if (match === null) return date;
2429
2771
  const d = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
2430
2772
  if (Number.isNaN(d.getTime())) return date;
2431
- const dayIdx = (d.getUTCDay() + 6) % 7;
2432
- d.setUTCDate(d.getUTCDate() - dayIdx + 3);
2433
- const isoYear = d.getUTCFullYear();
2434
- const firstThursday = new Date(Date.UTC(isoYear, 0, 4));
2435
- const firstIdx = (firstThursday.getUTCDay() + 6) % 7;
2436
- firstThursday.setUTCDate(firstThursday.getUTCDate() - firstIdx + 3);
2437
- const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 864e5));
2438
- return `${isoYear}-W${String(week).padStart(2, "0")}`;
2773
+ return isoWeekKey(d, "UTC");
2439
2774
  }
2440
2775
  function periodFileBase(period, date) {
2441
2776
  if (period === "monthly") return date.slice(0, 7);
@@ -2516,32 +2851,9 @@ var Runner = class {
2516
2851
  `\u6838\u5FC3\u4E0D\u652F\u6301 provider\u300C${settings.model.provider}\u300D\uFF1B\u53EF\u7528\uFF1A${handshakeResult.supportedProviders.join(" / ")}`
2517
2852
  );
2518
2853
  }
2519
- const modelKey = await resolveModelKey(settings, credentials, env);
2520
- if (modelKey.mode === "direct" && (modelKey.apiKey === void 0 || modelKey.apiKey === "")) {
2521
- throw new IpcError("MODEL_KEY_MISSING", `\u6CA1\u6709\u89E3\u6790\u5230\u6A21\u578B\u5BC6\u94A5\uFF08\u5F15\u7528 ${settings.model.apiKeyRef}\uFF09`);
2522
- }
2523
- let apiKey = modelKey.apiKey ?? "";
2524
- let baseURLOverride;
2525
- if (modelKey.mode === "proxy") {
2526
- const llm = this.deps.get("llm");
2527
- if (llm === void 0 || typeof llm.stream !== "function") {
2528
- throw new IpcError("MODEL_KEY_MISSING", "\u8BE5\u6A21\u578B\u9700\u8981\u56DE\u73AF\u4EE3\u7406\uFF0C\u4F46\u5BBF\u4E3B\u6CA1\u6709\u53EF\u7528\u7684 ctx.llm \u670D\u52A1");
2529
- }
2530
- proxy = await startProxy({
2531
- llm,
2532
- provider: settings.model.hostProvider !== "" ? settings.model.hostProvider : settings.model.provider,
2533
- model: settings.model.model,
2534
- temperature: settings.model.temperature,
2535
- maxTokens: settings.model.maxTokens,
2536
- timeoutMs: 12e4
2537
- });
2538
- baseURLOverride = proxy.url;
2539
- apiKey = proxy.token;
2540
- store.appendLog(
2541
- logFile,
2542
- `[proxy] \u56DE\u73AF\u4EE3\u7406\u5DF2\u542F\u52A8\uFF1A127.0.0.1:${proxy.port}\uFF08\u4E00\u6B21\u6027 token\uFF0C\u8FD0\u884C\u7ED3\u675F\u5373\u5173\u95ED\uFF09`
2543
- );
2544
- }
2854
+ const model = await this.openModelChannel({ settings, credentials, env, logFile });
2855
+ const apiKey = model.apiKey;
2856
+ proxy = model.proxy;
2545
2857
  const github = await resolveGithubToken(credentials, settings.github.authMode, settings.github.tokenRef, { env });
2546
2858
  if (github.token === void 0) {
2547
2859
  store.appendLog(logFile, "[github] \u672A\u627E\u5230 token\uFF1Apush/PR \u4F1A\u5931\u8D25\u5E76\u8FD4\u56DE GITHUB_AUTH\uFF08dryRun \u4E0D\u53D7\u5F71\u54CD\uFF09");
@@ -2556,7 +2868,7 @@ var Runner = class {
2556
2868
  period,
2557
2869
  dryRun,
2558
2870
  trigger: options.trigger === "schedule" ? "schedule" : "manual",
2559
- ...baseURLOverride === void 0 ? {} : { baseURLOverride },
2871
+ ...model.baseURLOverride === void 0 ? {} : { baseURLOverride: model.baseURLOverride },
2560
2872
  signal: controller.signal,
2561
2873
  secrets: [apiKey, github.token],
2562
2874
  onStderr: (line) => store.appendLog(logFile, line)
@@ -2629,6 +2941,220 @@ var Runner = class {
2629
2941
  }
2630
2942
  return summaries;
2631
2943
  }
2944
+ /**
2945
+ * 只跑发版订阅阶段(`run --stages releases`)。
2946
+ *
2947
+ * 与榜单运行共用同一把 `active` 锁:两条路径都会写同一份产物、读同一份订阅,
2948
+ * 并发跑会互相踩分支。和 `run()` 的区别只有三点:不带 dryRun、写完只落
2949
+ * `state.releaseRuns`(不碰 history.json)、自己**不**合并订阅 pending 队列
2950
+ * (`--stages releases` 让核心跳过那一步,合并只在完整 run 里发生)。
2951
+ */
2952
+ async runReleases(options) {
2953
+ const env = this.deps.env ?? process.env;
2954
+ const store = this.deps.store;
2955
+ const settings = store.readSettings();
2956
+ const credentials = credentialServices(this.deps.get);
2957
+ if (this.active !== void 0 || this.batching) {
2958
+ throw new IpcError("CONFIG_INVALID", "\u5DF2\u6709\u4E00\u6B21\u8FD0\u884C\u5728\u8FDB\u884C\u4E2D");
2959
+ }
2960
+ const problems = validateSettings(settings);
2961
+ if (problems.length > 0) {
2962
+ const first = problems[0];
2963
+ throw new IpcError(first?.code ?? "CONFIG_INVALID", `\u914D\u7F6E\u4E0D\u5B8C\u6574\uFF1A${first?.message ?? "\u672A\u77E5\u95EE\u9898"}`);
2964
+ }
2965
+ const repoIssue = repoProblem(settings.repo.path);
2966
+ if (repoIssue !== void 0) throw new IpcError(repoIssue.code, repoIssue.message);
2967
+ const id = newRunId();
2968
+ const controller = new AbortController();
2969
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2970
+ const startedMs = Date.now();
2971
+ this.active = { id, controller, startedAt };
2972
+ const logFile = store.newLogPath(id);
2973
+ let proxy;
2974
+ try {
2975
+ const core = await locateCore({ settings, env, allowDownload: settings.coreAutoDownload });
2976
+ if (core.downloaded === true) {
2977
+ store.appendLog(logFile, `[core] \u5DF2\u4E0B\u8F7D\u6838\u5FC3 ${core.tag ?? ""} \u2192 ${core.path}`);
2978
+ }
2979
+ const handshakeResult = await handshake(core, { signal: controller.signal });
2980
+ if (!handshakeResult.supportedProviders.includes(settings.model.provider)) {
2981
+ throw new IpcError(
2982
+ "MODEL_PROVIDER_UNSUPPORTED",
2983
+ `\u6838\u5FC3\u4E0D\u652F\u6301 provider\u300C${settings.model.provider}\u300D\uFF1B\u53EF\u7528\uFF1A${handshakeResult.supportedProviders.join(" / ")}`
2984
+ );
2985
+ }
2986
+ const model = await this.openModelChannel({ settings, credentials, env, logFile });
2987
+ proxy = model.proxy;
2988
+ const github = await resolveGithubToken(credentials, settings.github.authMode, settings.github.tokenRef, { env });
2989
+ if (github.token === void 0) {
2990
+ store.appendLog(logFile, "[github] \u672A\u627E\u5230 token\uFF1A\u62C9\u53D6 Release \u4F1A\u5931\u8D25\u5E76\u8FD4\u56DE GITHUB_AUTH");
2991
+ }
2992
+ const repoCount = await this.subscriptionCount(core.path, env);
2993
+ const result = await runCore({
2994
+ core,
2995
+ version: handshakeResult.version,
2996
+ settings,
2997
+ apiKey: model.apiKey,
2998
+ ...github.token === void 0 ? {} : { token: github.token },
2999
+ // 按发版调度时区算「今天」:与 lastReleaseWeek 的去重口径必须是同一天历。
3000
+ date: this.releaseDate(settings),
3001
+ period: settings.trending.since,
3002
+ dryRun: false,
3003
+ trigger: options.trigger === "schedule" ? "schedule" : "manual",
3004
+ stages: ["releases"],
3005
+ repoCount,
3006
+ ...model.baseURLOverride === void 0 ? {} : { baseURLOverride: model.baseURLOverride },
3007
+ signal: controller.signal,
3008
+ secrets: [model.apiKey, github.token],
3009
+ onStderr: (line) => store.appendLog(logFile, line)
3010
+ });
3011
+ return this.recordReleaseRun({
3012
+ id,
3013
+ outcome: result.outcome,
3014
+ settings,
3015
+ startedAt,
3016
+ logFile,
3017
+ cancelled: controller.signal.aborted
3018
+ });
3019
+ } catch (error) {
3020
+ const aborted = controller.signal.aborted || error instanceof IpcError && error.code === "CORE_CANCELLED";
3021
+ return this.recordReleaseFailure({ id, error, aborted, settings, startedAt, startedMs, logFile });
3022
+ } finally {
3023
+ if (proxy !== void 0) {
3024
+ const port = proxy.port;
3025
+ await proxy.close().catch(() => void 0);
3026
+ this.deps.store.appendLog(logFile, `[proxy] \u5DF2\u5173\u95ED 127.0.0.1:${port}`);
3027
+ }
3028
+ this.active = void 0;
3029
+ }
3030
+ }
3031
+ /** 发版运行用哪一天:发版调度时区下的「今天」(不是榜单调度时区,两者可独立配置)。 */
3032
+ releaseDate(settings, now = /* @__PURE__ */ new Date()) {
3033
+ try {
3034
+ return zonedDateString(now, settings.releases.schedule.timezone);
3035
+ } catch {
3036
+ return now.toISOString().slice(0, 10);
3037
+ }
3038
+ }
3039
+ /** 订阅仓库数(best-effort,只影响 auto 解析):任何失败都当 0,不阻塞发版运行。 */
3040
+ async subscriptionCount(corePath, env) {
3041
+ try {
3042
+ const result = await runSubscribe({ action: "list" }, { store: this.deps.store, env, corePath });
3043
+ return result.ok ? result.repos.length : 0;
3044
+ } catch {
3045
+ return 0;
3046
+ }
3047
+ }
3048
+ /**
3049
+ * 解析模型钥匙并(代理模式下)起回环代理。
3050
+ *
3051
+ * 榜单运行与发版运行共用这一段:代理的一次性 token 语义(空 key 会让核心静默降级成
3052
+ * 英文/离线假模型)只写一次,避免两份实现漂移。
3053
+ */
3054
+ async openModelChannel(input) {
3055
+ const { settings, env, logFile } = input;
3056
+ const modelKey = await resolveModelKey(settings, input.credentials, env);
3057
+ if (modelKey.mode === "direct" && (modelKey.apiKey === void 0 || modelKey.apiKey === "")) {
3058
+ throw new IpcError("MODEL_KEY_MISSING", `\u6CA1\u6709\u89E3\u6790\u5230\u6A21\u578B\u5BC6\u94A5\uFF08\u5F15\u7528 ${settings.model.apiKeyRef}\uFF09`);
3059
+ }
3060
+ if (modelKey.mode !== "proxy") return { apiKey: modelKey.apiKey ?? "" };
3061
+ const llm = this.deps.get("llm");
3062
+ if (llm === void 0 || typeof llm.stream !== "function") {
3063
+ throw new IpcError("MODEL_KEY_MISSING", "\u8BE5\u6A21\u578B\u9700\u8981\u56DE\u73AF\u4EE3\u7406\uFF0C\u4F46\u5BBF\u4E3B\u6CA1\u6709\u53EF\u7528\u7684 ctx.llm \u670D\u52A1");
3064
+ }
3065
+ const proxy = await startProxy({
3066
+ llm,
3067
+ provider: settings.model.hostProvider !== "" ? settings.model.hostProvider : settings.model.provider,
3068
+ model: settings.model.model,
3069
+ temperature: settings.model.temperature,
3070
+ maxTokens: settings.model.maxTokens,
3071
+ timeoutMs: 12e4
3072
+ });
3073
+ this.deps.store.appendLog(logFile, `[proxy] \u56DE\u73AF\u4EE3\u7406\u5DF2\u542F\u52A8\uFF1A127.0.0.1:${proxy.port}\uFF08\u4E00\u6B21\u6027 token\uFF0C\u8FD0\u884C\u7ED3\u675F\u5373\u5173\u95ED\uFF09`);
3074
+ return { apiKey: proxy.token, baseURLOverride: proxy.url, proxy };
3075
+ }
3076
+ /** 把 `run --json` 的发版阶段结果投影成一条发版运行记录并落盘。 */
3077
+ recordReleaseRun(input) {
3078
+ const { outcome, settings } = input;
3079
+ const status = input.cancelled ? "cancelled" : outcome.skipped ? "skipped" : outcome.ok ? "ok" : "failed";
3080
+ const releases = outcome.releases;
3081
+ const record = {
3082
+ id: input.id,
3083
+ startedAt: input.startedAt,
3084
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3085
+ status,
3086
+ checked: counterOf(releases?.checked),
3087
+ changed: counterOf(releases?.changed),
3088
+ newTags: counterOf(releases?.newReleases),
3089
+ summarized: counterOf(releases?.summarized),
3090
+ degraded: counterOf(releases?.degraded),
3091
+ failed: counterOf(releases?.failed),
3092
+ rateLimited: counterOf(releases?.rateLimited),
3093
+ durationMs: counterOf(releases?.durationMs),
3094
+ ...releases?.usage === void 0 ? {} : { tokens: releases.usage.totalTokens },
3095
+ ...outcome.commit === void 0 ? {} : { commit: outcome.commit },
3096
+ ...outcome.prUrl === void 0 ? {} : { prUrl: outcome.prUrl },
3097
+ ...outcome.branch === void 0 ? {} : { branch: outcome.branch },
3098
+ ...outcome.error === void 0 ? input.cancelled ? { errorCode: "CORE_CANCELLED", errorMessage: "\u8FD0\u884C\u5DF2\u53D6\u6D88\uFF1A\u6838\u5FC3\u8FDB\u7A0B\u5DF2\u7EC8\u6B62\uFF0C\u672A\u63D0\u4EA4\u4EFB\u4F55\u72B6\u6001" } : {} : { errorCode: outcome.error.code, errorMessage: redact(outcome.error.message) },
3099
+ ...outcome.coreVersion === "" ? {} : { coreVersion: outcome.coreVersion }
3100
+ };
3101
+ this.persistReleaseRun(record, settings, input.logFile);
3102
+ return record;
3103
+ }
3104
+ /** 运行在拿到信封之前就失败(定位/握手/模型/代理/超时/取消):也要留一条可复盘记录。 */
3105
+ recordReleaseFailure(input) {
3106
+ const code = input.aborted ? "CORE_CANCELLED" : input.error instanceof IpcError ? input.error.code : "INTERNAL";
3107
+ const message = redact(input.error instanceof Error ? input.error.message : String(input.error));
3108
+ const record = {
3109
+ id: input.id,
3110
+ startedAt: input.startedAt,
3111
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3112
+ status: input.aborted ? "cancelled" : "failed",
3113
+ checked: 0,
3114
+ changed: 0,
3115
+ newTags: 0,
3116
+ summarized: 0,
3117
+ degraded: 0,
3118
+ failed: 0,
3119
+ rateLimited: 0,
3120
+ durationMs: Date.now() - input.startedMs,
3121
+ errorCode: code,
3122
+ errorMessage: message
3123
+ };
3124
+ this.persistReleaseRun(record, input.settings, input.logFile);
3125
+ return record;
3126
+ }
3127
+ /**
3128
+ * 落盘发版运行:只写 state(history.json 是榜单的),顺带推进 lastReleaseAt;
3129
+ * 只有 ok/skipped 才锁 `lastReleaseWeek`——失败不锁,调度下一 tick 才有机会重试。
3130
+ */
3131
+ persistReleaseRun(record, settings, logFile) {
3132
+ const store = this.deps.store;
3133
+ const succeeded = record.status === "ok" || record.status === "skipped";
3134
+ let week;
3135
+ if (succeeded) {
3136
+ try {
3137
+ week = isoWeekKey(new Date(record.finishedAt), settings.releases.schedule.timezone);
3138
+ } catch {
3139
+ week = void 0;
3140
+ }
3141
+ }
3142
+ store.appendReleaseRun(record, {
3143
+ lastReleaseAt: record.finishedAt,
3144
+ ...week === void 0 ? {} : { lastReleaseWeek: week }
3145
+ });
3146
+ store.appendLog(
3147
+ logFile,
3148
+ `[release] ${record.status} checked=${record.checked} changed=${record.changed} newTags=${record.newTags} summarized=${record.summarized} degraded=${record.degraded} failed=${record.failed} rateLimited=${record.rateLimited} durationMs=${record.durationMs}`
3149
+ );
3150
+ this.deps.logger?.("release.finished", {
3151
+ id: record.id,
3152
+ status: record.status,
3153
+ checked: record.checked,
3154
+ changed: record.changed,
3155
+ coreVersion: record.coreVersion ?? null
3156
+ });
3157
+ }
2632
3158
  summarize(input) {
2633
3159
  const { outcome } = input;
2634
3160
  const error = outcome.error === void 0 ? void 0 : (() => {
@@ -2703,19 +3229,19 @@ var Runner = class {
2703
3229
  return void 0;
2704
3230
  };
2705
3231
  const markdown = outcome.files.filter((file) => {
2706
- const ext = path8.extname(file.path).toLowerCase();
2707
- const base = path8.basename(file.path).toLowerCase();
3232
+ const ext = path9.extname(file.path).toLowerCase();
3233
+ const base = path9.basename(file.path).toLowerCase();
2708
3234
  const normalized = file.path.replace(/\\/g, "/");
2709
3235
  const inReleases = normalized === "releases" || normalized.startsWith("releases/");
2710
3236
  return ext === ".md" && base !== "readme.md" && base !== "index.md" && !inReleases;
2711
3237
  });
2712
3238
  if (markdown.length === 0) return skip("no-markdown");
2713
3239
  const expected = period === void 0 ? `${date}.md` : `${periodFileBase(period, date)}.md`;
2714
- const named = markdown.filter((file) => path8.basename(file.path) === expected);
3240
+ const named = markdown.filter((file) => path9.basename(file.path) === expected);
2715
3241
  const chosen = named.length > 0 ? named[0] : markdown.length === 1 ? markdown[0] : void 0;
2716
3242
  if (chosen === void 0) return skip("ambiguous", { candidates: markdown.length, expected, period: period ?? null });
2717
3243
  const repoPath = settings.repo.path;
2718
- const resolved = path8.resolve(repoPath, chosen.path);
3244
+ const resolved = path9.resolve(repoPath, chosen.path);
2719
3245
  if (!isInside(repoPath, resolved)) return skip("outside-repo", { path: chosen.path });
2720
3246
  let fileReal;
2721
3247
  try {
@@ -2748,7 +3274,7 @@ var Runner = class {
2748
3274
  return skip("write-failed", { path: chosen.path });
2749
3275
  }
2750
3276
  return {
2751
- file: path8.relative(repoPath, resolved).split(path8.sep).join("/"),
3277
+ file: path9.relative(repoPath, resolved).split(path9.sep).join("/"),
2752
3278
  bytes: stored.length,
2753
3279
  sha256: createHash2("sha256").update(stored).digest("hex")
2754
3280
  };
@@ -2923,15 +3449,15 @@ __export(import_cli_exports, {
2923
3449
  parseSimpleYaml: () => parseSimpleYaml
2924
3450
  });
2925
3451
  import { readFileSync as readFileSync4 } from "fs";
2926
- import os3 from "os";
2927
- import path9 from "path";
2928
- function cliConfigCandidates(env = process.env, home = os3.homedir()) {
3452
+ import os4 from "os";
3453
+ import path10 from "path";
3454
+ function cliConfigCandidates(env = process.env, home = os4.homedir()) {
2929
3455
  const xdg = env.XDG_CONFIG_HOME?.trim();
2930
- const base = xdg !== void 0 && xdg !== "" ? path9.resolve(xdg) : path9.join(home, ".config");
3456
+ const base = xdg !== void 0 && xdg !== "" ? path10.resolve(xdg) : path10.join(home, ".config");
2931
3457
  return [
2932
3458
  env.TRENDradar_CONFIG?.trim() ?? "",
2933
- path9.join(base, "trendradar", "config.yaml"),
2934
- path9.join(home, ".trendradar", "config.yaml")
3459
+ path10.join(base, "trendradar", "config.yaml"),
3460
+ path10.join(home, ".trendradar", "config.yaml")
2935
3461
  ].filter((item) => item !== "");
2936
3462
  }
2937
3463
  function unquote(raw) {
@@ -3365,7 +3891,7 @@ __export(repo_exports, {
3365
3891
  readDefaultBranch: () => readDefaultBranch
3366
3892
  });
3367
3893
  import { statSync as statSync6 } from "fs";
3368
- import path10 from "path";
3894
+ import path11 from "path";
3369
3895
  function isDirectory(dir) {
3370
3896
  try {
3371
3897
  return statSync6(dir).isDirectory();
@@ -3384,7 +3910,7 @@ async function git(dir, args, env) {
3384
3910
  async function readDefaultBranch(repoPath, options = {}) {
3385
3911
  const dir = repoPath.trim();
3386
3912
  if (dir === "") return { source: "none", message: "\u4ED3\u5E93\u8DEF\u5F84\u4E3A\u7A7A" };
3387
- if (!path10.isAbsolute(dir)) return { source: "none", message: "\u4ED3\u5E93\u8DEF\u5F84\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84" };
3913
+ if (!path11.isAbsolute(dir)) return { source: "none", message: "\u4ED3\u5E93\u8DEF\u5F84\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84" };
3388
3914
  if (!isDirectory(dir)) return { source: "none", message: "\u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u4E0D\u53EF\u8BBF\u95EE" };
3389
3915
  const env = options.env ?? process.env;
3390
3916
  const origin = await git(dir, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], env);
@@ -3399,74 +3925,6 @@ async function readDefaultBranch(repoPath, options = {}) {
3399
3925
  return { source: "none", message: "\u672A\u8BFB\u53D6\u5230\u9ED8\u8BA4\u5206\u652F\uFF08\u53EF\u80FD\u662F\u7A7A\u4ED3\u5E93\u6216\u6CA1\u6709 origin\uFF09" };
3400
3926
  }
3401
3927
 
3402
- // src/host/subscribe.ts
3403
- var subscribe_exports = {};
3404
- __export(subscribe_exports, {
3405
- SUBSCRIBE_TIMEOUT_MS: () => SUBSCRIBE_TIMEOUT_MS,
3406
- isValidRepo: () => isValidRepo,
3407
- runSubscribe: () => runSubscribe
3408
- });
3409
- import { mkdtempSync as mkdtempSync2, writeFileSync as writeFileSync2, rmSync as rmSync2 } from "fs";
3410
- import os4 from "os";
3411
- import path11 from "path";
3412
- var SUBSCRIBE_TIMEOUT_MS = 8e3;
3413
- var REPO_PATTERN2 = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
3414
- function isValidRepo(repo) {
3415
- return REPO_PATTERN2.test(repo.trim());
3416
- }
3417
- function writeTempConfig(settings) {
3418
- const dir = mkdtempSync2(path11.join(os4.tmpdir(), "dsh-subscribe-"));
3419
- const file = path11.join(dir, "config.yml");
3420
- const yaml = `${toYaml(toCoreConfig(settings, ""))}
3421
- `;
3422
- writeFileSync2(file, yaml, { mode: 384 });
3423
- return { file, dir };
3424
- }
3425
- function requireUsableConfig(settings) {
3426
- const problems = validateSettings(settings);
3427
- if (problems.length > 0) {
3428
- const first = problems[0];
3429
- throw new IpcError("CONFIG_INVALID", `\u914D\u7F6E\u4E0D\u5B8C\u6574\uFF0C\u65E0\u6CD5\u7BA1\u7406\u8BA2\u9605\uFF1A${first?.message ?? "\u672A\u77E5\u95EE\u9898"}`);
3430
- }
3431
- }
3432
- async function runSubscribe(input, deps) {
3433
- const env = deps.env ?? process.env;
3434
- const settings = deps.store.readSettings();
3435
- requireUsableConfig(settings);
3436
- if (input.action !== "list") {
3437
- const repo = (input.repo ?? "").trim();
3438
- if (!isValidRepo(repo)) {
3439
- throw new IpcError("CONFIG_INVALID", `\u4ED3\u5E93\u5FC5\u987B\u662F owner/repo \u5F62\u6001\uFF1A${repo || "(\u7A7A)"}`);
3440
- }
3441
- }
3442
- const corePath = deps.corePath !== void 0 ? deps.corePath : (await locateCore({ settings, env, allowDownload: settings.coreAutoDownload })).path;
3443
- const { file, dir } = writeTempConfig(settings);
3444
- try {
3445
- const request = {
3446
- ipcSchema: 2,
3447
- command: "subscribe",
3448
- action: input.action,
3449
- ...input.action === "list" ? {} : { repo: (input.repo ?? "").trim() },
3450
- configPath: file
3451
- };
3452
- const result = await execCapture(corePath, ["subscribe", "--json"], {
3453
- env: sanitizedEnv(env),
3454
- input: JSON.stringify(request),
3455
- timeoutMs: SUBSCRIBE_TIMEOUT_MS
3456
- });
3457
- if (result.timedOut) {
3458
- throw new IpcError("SUBSCRIBE_FAILED", `\u8BA2\u9605\u547D\u4EE4\u8D85\u65F6\uFF08${Math.round(SUBSCRIBE_TIMEOUT_MS / 1e3)} s\uFF09`);
3459
- }
3460
- if (result.stdout.trim() === "") {
3461
- const detail = result.stderr.trim();
3462
- throw new IpcError("SUBSCRIBE_FAILED", `\u8BA2\u9605\u547D\u4EE4\u65E0\u8F93\u51FA${detail === "" ? "" : `\uFF1A${detail}`}`);
3463
- }
3464
- return parseSubscribeResult(result.stdout);
3465
- } finally {
3466
- rmSync2(dir, { recursive: true, force: true });
3467
- }
3468
- }
3469
-
3470
3928
  // src/host/routes.ts
3471
3929
  var routes_exports = {};
3472
3930
  __export(routes_exports, {
@@ -3623,7 +4081,32 @@ async function dispatch(method, pathname, url, request, deps) {
3623
4081
  if (!result.ok) {
3624
4082
  throw new IpcError("SUBSCRIBE_FAILED", result.error?.message ?? "\u8BFB\u53D6\u8BA2\u9605\u5217\u8868\u5931\u8D25");
3625
4083
  }
3626
- return { repos: result.repos };
4084
+ return { repos: result.repos, items: result.items ?? [] };
4085
+ }
4086
+ if (method === "GET" && pathname === "/subscriptions/releases") {
4087
+ const repo = (url.searchParams.get("repo") ?? "").trim();
4088
+ if (!isValidRepo(repo)) {
4089
+ throw new IpcError("CONFIG_INVALID", `\u4ED3\u5E93\u5FC5\u987B\u662F owner/repo \u5F62\u6001\uFF1A${repo || "(\u7A7A)"}`);
4090
+ }
4091
+ const volumeRaw = url.searchParams.get("volume");
4092
+ let volume;
4093
+ if (volumeRaw !== null && volumeRaw !== "") {
4094
+ if (!/^\d+$/.test(volumeRaw.trim()) || Number(volumeRaw) < 1) {
4095
+ throw new IpcError("CONFIG_INVALID", `\u5377\u53F7\u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF1A${volumeRaw}`);
4096
+ }
4097
+ volume = Number(volumeRaw);
4098
+ }
4099
+ const result = await runSubscribe(
4100
+ { action: "releases", repo, ...volume === void 0 ? {} : { volume } },
4101
+ { store: deps.store, env }
4102
+ );
4103
+ if (!result.ok) {
4104
+ throw new IpcError("SUBSCRIBE_FAILED", result.error?.message ?? `\u8BFB\u53D6 ${repo} \u7684\u53D1\u7248\u6587\u6863\u5931\u8D25`);
4105
+ }
4106
+ if (result.doc === void 0) {
4107
+ throw new IpcError("SUBSCRIBE_FAILED", `\u6838\u5FC3\u6CA1\u6709\u8FD4\u56DE ${repo} \u7684\u53D1\u7248\u6587\u6863`);
4108
+ }
4109
+ return { ...result.doc };
3627
4110
  }
3628
4111
  if (method === "POST" && pathname === "/subscriptions") {
3629
4112
  const body = await readBody2(request);
@@ -3638,7 +4121,7 @@ async function dispatch(method, pathname, url, request, deps) {
3638
4121
  }
3639
4122
  }
3640
4123
  const latest = await runSubscribe({ action: "list" }, { store: deps.store, env });
3641
- return { repos: latest.ok ? latest.repos : [] };
4124
+ return { repos: latest.ok ? latest.repos : [], items: latest.ok ? latest.items ?? [] : [] };
3642
4125
  }
3643
4126
  if (method === "POST" && pathname === "/github/test") {
3644
4127
  const body = await readBody2(request);
@@ -3757,6 +4240,10 @@ async function statePayload(deps, settings) {
3757
4240
  pendingPr,
3758
4241
  ...pendingPrState === void 0 ? {} : { pendingPrState },
3759
4242
  history,
4243
+ /** 最近 30 次发版运行(HTTP 镜像:`Store.readReleaseRuns`,IPC 侧无对应命令,纯展示)。 */
4244
+ releaseRuns: deps.store.readReleaseRuns(),
4245
+ /** 最近一次发版运行结束时间(失败也算);界面「上次发版」读它。 */
4246
+ lastReleaseAt: state.lastReleaseAt ?? null,
3760
4247
  /** 历史里全部运行的 token 累计(读时求和,不落盘);全无数据则省略。 */
3761
4248
  ...(() => {
3762
4249
  const totalTokens = sumTokens(history);
@@ -4042,6 +4529,7 @@ export {
4042
4529
  proxy_exports,
4043
4530
  Scheduler,
4044
4531
  schedule_exports,
4532
+ subscribe_exports,
4045
4533
  Runner,
4046
4534
  runner_exports,
4047
4535
  readPluginInfo,
@@ -4051,7 +4539,6 @@ export {
4051
4539
  pending_pr_exports,
4052
4540
  preflight_exports,
4053
4541
  repo_exports,
4054
- subscribe_exports,
4055
4542
  ROUTE_PREFIX,
4056
4543
  createRoutes,
4057
4544
  routes_exports,