@senpi/runtime 1.1.0-dev.runtime-phase-2.20260512080327 → 1.1.0-dev.runtime-phase-2.20260513122539

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/cli/senpi-commands.d.ts.map +1 -1
  2. package/dist/cli/senpi-commands.js +93 -0
  3. package/dist/cli/senpi-commands.js.map +1 -1
  4. package/dist/index.d.ts +7 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +61 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/skills-manager/bootstrap.d.ts +26 -0
  9. package/dist/skills-manager/bootstrap.d.ts.map +1 -0
  10. package/dist/skills-manager/bootstrap.js +68 -0
  11. package/dist/skills-manager/bootstrap.js.map +1 -0
  12. package/dist/skills-manager/constants.d.ts +12 -0
  13. package/dist/skills-manager/constants.d.ts.map +1 -0
  14. package/dist/skills-manager/constants.js +15 -0
  15. package/dist/skills-manager/constants.js.map +1 -0
  16. package/dist/skills-manager/coordinator.d.ts +209 -0
  17. package/dist/skills-manager/coordinator.d.ts.map +1 -0
  18. package/dist/skills-manager/coordinator.js +757 -0
  19. package/dist/skills-manager/coordinator.js.map +1 -0
  20. package/dist/skills-manager/fetcher.d.ts +28 -0
  21. package/dist/skills-manager/fetcher.d.ts.map +1 -0
  22. package/dist/skills-manager/fetcher.js +92 -0
  23. package/dist/skills-manager/fetcher.js.map +1 -0
  24. package/dist/skills-manager/index.d.ts +5 -0
  25. package/dist/skills-manager/index.d.ts.map +1 -0
  26. package/dist/skills-manager/index.js +4 -0
  27. package/dist/skills-manager/index.js.map +1 -0
  28. package/dist/skills-manager/state-store.d.ts +34 -0
  29. package/dist/skills-manager/state-store.d.ts.map +1 -0
  30. package/dist/skills-manager/state-store.js +103 -0
  31. package/dist/skills-manager/state-store.js.map +1 -0
  32. package/dist/skills-manager/types.d.ts +127 -0
  33. package/dist/skills-manager/types.d.ts.map +1 -0
  34. package/dist/skills-manager/types.js +2 -0
  35. package/dist/skills-manager/types.js.map +1 -0
  36. package/dist/skills-manager/utils.d.ts +46 -0
  37. package/dist/skills-manager/utils.d.ts.map +1 -0
  38. package/dist/skills-manager/utils.js +87 -0
  39. package/dist/skills-manager/utils.js.map +1 -0
  40. package/dist/skills-manager/version-manager.d.ts +61 -0
  41. package/dist/skills-manager/version-manager.d.ts.map +1 -0
  42. package/dist/skills-manager/version-manager.js +142 -0
  43. package/dist/skills-manager/version-manager.js.map +1 -0
  44. package/dist/skills-manager/writer.d.ts +28 -0
  45. package/dist/skills-manager/writer.d.ts.map +1 -0
  46. package/dist/skills-manager/writer.js +101 -0
  47. package/dist/skills-manager/writer.js.map +1 -0
  48. package/openclaw.plugin.json +29 -0
  49. package/package.json +3 -1
@@ -0,0 +1,757 @@
1
+ /**
2
+ * Central lifecycle manager for Senpi skills auto-update.
3
+ *
4
+ * Mirrors AutoUpdateCoordinator from src/runtime/auto-update/coordinator.ts:
5
+ * same runToken + stopped + running triple for thread-safety, same
6
+ * self-managed setTimeout loop, same injectable-dependency pattern for
7
+ * testability.
8
+ *
9
+ * On start(): bootstrap missing skills then schedule the first poll.
10
+ * Each tick(): resolve skill names, check remote versions in parallel,
11
+ * auto-apply patch/minor, notify on major (deduped), skip on none/unknown.
12
+ */
13
+ import { readdir } from "node:fs/promises";
14
+ import { bootstrapSkillsIfNeeded } from "./bootstrap.js";
15
+ import { cloneSkillsRepo } from "./fetcher.js";
16
+ import { SkillsManagerStateStore } from "./state-store.js";
17
+ import { checkSkillVersion } from "./version-manager.js";
18
+ import { installSkill } from "./writer.js";
19
+ import { SKILLS_RAW_BASE_URL, SKILLS_REPO_URL } from "./constants.js";
20
+ import { createDefaultSkillState, createDefaultSkillsManagerState, formatErr } from "./utils.js";
21
+ export class SkillsUpdateCoordinator {
22
+ options;
23
+ store;
24
+ timer = null;
25
+ operationChain = Promise.resolve();
26
+ stopped = true;
27
+ runToken = 0;
28
+ state = createDefaultSkillsManagerState();
29
+ snapshot;
30
+ /** Latest remote version per skill name — populated during ticks. */
31
+ latestVersions = new Map();
32
+ constructor(options) {
33
+ this.options = options;
34
+ this.store = new SkillsManagerStateStore(options.stateDir, options.config.skillsDir, options.logger);
35
+ this.snapshot = this.buildSnapshot("disabled", "not-started");
36
+ }
37
+ /** Load persisted state, bootstrap missing skills, and schedule the first poll. */
38
+ async start() {
39
+ this.runToken += 1;
40
+ this.stopped = false;
41
+ const { logger, config } = this.options;
42
+ if (!config.enabled) {
43
+ this.stopped = true;
44
+ this.snapshot = this.buildSnapshot("disabled", "skills-manager-disabled");
45
+ logger.info("[senpi-skills-manager] disabled by config");
46
+ return;
47
+ }
48
+ logger.info(`[senpi-skills-manager] starting | skillsDir=${config.skillsDir} pollInterval=${config.pollIntervalMinutes}m skills=${config.skills.join(",") || "(discover)"}`);
49
+ this.state = await this.store.load();
50
+ const bootstrapFn = this.options.deps?.bootstrapSkillsIfNeeded ?? bootstrapSkillsIfNeeded;
51
+ try {
52
+ await bootstrapFn(config, logger);
53
+ }
54
+ catch (err) {
55
+ logger.warn(`[senpi-skills-manager] bootstrap error (non-fatal): ${formatErr(err)}`);
56
+ }
57
+ this.snapshot = this.buildSnapshot("healthy", "started");
58
+ // First tick fires after 1 s (Math.max(1_000, 0) floor in scheduleNext)
59
+ this.scheduleNext(0, this.runToken);
60
+ }
61
+ /**
62
+ * Cancel the next scheduled poll, prevent in-flight ticks from rescheduling,
63
+ * and wait for any currently-executing tick or manual RPC to finish so the
64
+ * caller can rely on no async work still touching state after `stop()`
65
+ * resolves. Without this drain, a caller `await coordinator.stop()` could
66
+ * proceed to e.g. delete the state dir while a tick mid-write fires.
67
+ */
68
+ async stop() {
69
+ this.runToken += 1;
70
+ this.stopped = true;
71
+ if (this.timer) {
72
+ clearTimeout(this.timer);
73
+ this.timer = null;
74
+ }
75
+ const inFlight = this.operationChain;
76
+ this.options.logger.info("[senpi-skills-manager] stop requested — draining in-flight operations");
77
+ // operationChain never rejects (runSerialized catches via try/finally before
78
+ // releasing the queue), so this await always resolves.
79
+ await inFlight;
80
+ this.options.logger.info("[senpi-skills-manager] stopped");
81
+ }
82
+ /** Return the latest point-in-time snapshot for gateway RPCs. */
83
+ getSnapshot() {
84
+ return this.snapshot;
85
+ }
86
+ // ── Manual trigger methods (called by gateway RPCs) ────────────────────────
87
+ /**
88
+ * Force an immediate version check and apply for one named skill.
89
+ *
90
+ * Skips installation when the update kind is `"none"` (already current) or
91
+ * `"unknown"` (remote version could not be classified — e.g. the fetch
92
+ * failed or the remote `version` field is missing). Falling through on
93
+ * `"unknown"` would write `installedVersion: null` and lose tracking, so we
94
+ * treat it the same as `"none"` and return `updated: false`.
95
+ *
96
+ * @throws On network or install error so the gateway RPC can return ok=false.
97
+ */
98
+ async updateSkill(skillName) {
99
+ return this.runSerialized("updateSkill", async () => {
100
+ const { logger } = this.options;
101
+ logger.info(`[senpi-skills-manager] updateSkill invoked | skill=${skillName}`);
102
+ const checkVersionFn = this.resolveCheckFn();
103
+ let versionInfo;
104
+ try {
105
+ versionInfo = await checkVersionFn(this.options.config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
106
+ }
107
+ catch (err) {
108
+ // Version check threw (5xx, network error, timeout, body-parse failure
109
+ // — all now propagate from checkSkillVersion after the version-manager
110
+ // re-throw fix). Mirror the tick/updateAll paths and record the
111
+ // failure on this skill's state before rethrowing so the gateway RPC
112
+ // still returns ok=false to the caller.
113
+ const errorMessage = formatErr(err);
114
+ logger.warn(`[senpi-skills-manager] updateSkill — version check failed | skill=${skillName} err=${errorMessage}`);
115
+ this.recordCheckFailure(skillName, errorMessage);
116
+ await this.persistState();
117
+ this.snapshot = this.buildSnapshot(this.snapshot.health, "manual-update-failed", errorMessage);
118
+ throw err;
119
+ }
120
+ // Record the remote version we just learned so the snapshot's latestVersion
121
+ // field reflects it on the next getSnapshot() call — even when no install
122
+ // happens (skip paths below).
123
+ this.latestVersions.set(skillName, versionInfo.remoteVersion);
124
+ // Mirror runTick: every skill whose version we just checked gets its
125
+ // `lastCheckedAt` updated (and `installedVersion` refreshed from the local
126
+ // read) regardless of whether it proceeds to install. Without this, skills
127
+ // updated only via manual RPCs would show `lastCheckedAt: null` in the
128
+ // status snapshot until the next automatic tick fires (up to 60 min away).
129
+ const checkedState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
130
+ checkedState.lastCheckedAt = new Date().toISOString();
131
+ if (versionInfo.localVersion !== null) {
132
+ checkedState.installedVersion = versionInfo.localVersion;
133
+ }
134
+ // NOTE: do NOT clear failure state here. See runTick for rationale —
135
+ // a successful version check is not proof of operational health. Failure
136
+ // counters are only reset by `recordSuccess` after a successful install.
137
+ this.state.skills[skillName] = checkedState;
138
+ logger.info(`[senpi-skills-manager] updateSkill check result | skill=${skillName} local=${versionInfo.localVersion ?? "none"} remote=${versionInfo.remoteVersion ?? "unknown"} kind=${versionInfo.updateKind}`);
139
+ if (versionInfo.updateKind === "none" || versionInfo.updateKind === "unknown") {
140
+ logger.info(`[senpi-skills-manager] updateSkill skipped | skill=${skillName} reason=${versionInfo.updateKind}`);
141
+ // Persist so the `lastCheckedAt` timestamp survives a gateway restart
142
+ // even on skip paths (otherwise the next load() would see a stale null).
143
+ await this.persistState();
144
+ // Refresh snapshot so the status RPC doesn't return pre-check data.
145
+ this.snapshot = this.buildSnapshot(this.snapshot.health, "manual-update-skipped");
146
+ return { updated: false, version: versionInfo.localVersion };
147
+ }
148
+ try {
149
+ const installedVersion = await this.applyUpdate(skillName, versionInfo.remoteVersion);
150
+ logger.info(`[senpi-skills-manager] updateSkill complete | skill=${skillName} version=${installedVersion ?? "unknown"}`);
151
+ // Refresh snapshot so the status RPC reflects the freshly-installed version
152
+ // immediately, rather than waiting for the next poll tick (up to 60 min).
153
+ this.snapshot = this.buildSnapshot("healthy", "manual-update-complete");
154
+ return { updated: true, version: installedVersion };
155
+ }
156
+ catch (err) {
157
+ const errorMessage = formatErr(err);
158
+ // applyUpdate has already recorded per-skill failure state (consecutiveFailures,
159
+ // lastFailureAt, lastFailureReason) and persisted before throwing. We repeat
160
+ // persistState() here as a defensive backstop in case applyUpdate's internal
161
+ // persist silently failed, and also so the updated lastCheckedAt we set
162
+ // earlier in this method is guaranteed to reach disk on the error path.
163
+ await this.persistState();
164
+ this.snapshot = this.buildSnapshot(this.snapshot.health, "manual-update-failed", errorMessage);
165
+ throw err;
166
+ }
167
+ });
168
+ }
169
+ /**
170
+ * Force an immediate version check and apply for all discovered/configured skills.
171
+ * Individual failures do not abort the remaining skills.
172
+ *
173
+ * All skills share a single `git clone` — the repo is cloned once, every
174
+ * skill that needs an update is installed from that same checkout, then the
175
+ * checkout is cleaned up. A previous version called `updateSkill` in a loop,
176
+ * which caused one full clone per skill (N redundant clones for N skills);
177
+ * this mirrors the tick path's batching instead.
178
+ */
179
+ async updateAll() {
180
+ return this.runSerialized("updateAll", async () => {
181
+ const { logger, config } = this.options;
182
+ logger.info("[senpi-skills-manager] updateAll invoked");
183
+ const skillNames = await this.resolveSkillNames();
184
+ if (skillNames.length === 0) {
185
+ logger.info("[senpi-skills-manager] updateAll — no skills to check");
186
+ return [];
187
+ }
188
+ logger.info(`[senpi-skills-manager] updateAll — checking versions | skills=${skillNames.join(",")}`);
189
+ const checkVersionFn = this.resolveCheckFn();
190
+ // Check every skill's version in parallel — one rejection does not abort others.
191
+ const versionChecks = await Promise.allSettled(skillNames.map(async (skillName) => {
192
+ const versionInfo = await checkVersionFn(config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
193
+ this.latestVersions.set(skillName, versionInfo.remoteVersion);
194
+ return { name: skillName, ...versionInfo };
195
+ }));
196
+ const results = [];
197
+ const skillsToInstall = [];
198
+ for (let index = 0; index < versionChecks.length; index++) {
199
+ const skillName = skillNames[index];
200
+ const checkResult = versionChecks[index];
201
+ if (checkResult.status === "rejected") {
202
+ const errorMessage = formatErr(checkResult.reason);
203
+ logger.warn(`[senpi-skills-manager] updateAll — version check failed | skill=${skillName} err=${errorMessage}`);
204
+ // Mirror runTick: rejected checks update `lastCheckedAt`, bump the
205
+ // failure counter, and capture the reason. Otherwise a manual
206
+ // updateAll run could swallow a per-skill check failure entirely —
207
+ // the caller sees `error` in the returned array, but the snapshot
208
+ // and persisted state never reflect it.
209
+ this.recordCheckFailure(skillName, errorMessage);
210
+ results.push({ name: skillName, updated: false, version: null, error: errorMessage });
211
+ continue;
212
+ }
213
+ const { localVersion, updateKind } = checkResult.value;
214
+ // Mirror runTick: every successfully-checked skill gets `lastCheckedAt`
215
+ // updated and `installedVersion` refreshed from the local read, whether
216
+ // or not it proceeds to install. Otherwise skills touched only by
217
+ // manual RPCs would show `lastCheckedAt: null` in the status snapshot
218
+ // until the next automatic tick.
219
+ const skillState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
220
+ skillState.lastCheckedAt = new Date().toISOString();
221
+ if (localVersion !== null)
222
+ skillState.installedVersion = localVersion;
223
+ // NOTE: do NOT clear failure state here. See runTick for rationale —
224
+ // a successful version check is not proof of operational health.
225
+ // Failure counters are only reset by `recordSuccess` after a
226
+ // successful install.
227
+ this.state.skills[skillName] = skillState;
228
+ // Same skip policy as updateSkill: "none" and "unknown" leave the skill alone.
229
+ if (updateKind === "none" || updateKind === "unknown") {
230
+ logger.info(`[senpi-skills-manager] updateAll — skipping | skill=${skillName} reason=${updateKind}`);
231
+ results.push({ name: skillName, updated: false, version: localVersion });
232
+ continue;
233
+ }
234
+ skillsToInstall.push(skillName);
235
+ }
236
+ if (skillsToInstall.length === 0) {
237
+ logger.info(`[senpi-skills-manager] updateAll complete — no installs needed | checked=${skillNames.length}`);
238
+ // Persist so the `lastCheckedAt` timestamps we just set on every
239
+ // successfully-checked skill survive a gateway restart.
240
+ await this.persistState();
241
+ // No installs, but we may have learned new remote versions from the
242
+ // parallel checks above — refresh the snapshot so getSnapshot() doesn't
243
+ // return pre-check data.
244
+ this.snapshot = this.buildSnapshot(this.snapshot.health, "manual-update-skipped");
245
+ return results;
246
+ }
247
+ logger.info(`[senpi-skills-manager] updateAll — installing | count=${skillsToInstall.length} skills=${skillsToInstall.join(",")}`);
248
+ // Single clone, install many.
249
+ let installOutcomes;
250
+ try {
251
+ installOutcomes = await this.cloneAndInstallMany(skillsToInstall);
252
+ }
253
+ catch (err) {
254
+ const errorMessage = `clone failed: ${formatErr(err)}`;
255
+ logger.warn(`[senpi-skills-manager] updateAll — ${errorMessage}`);
256
+ for (const skillName of skillsToInstall) {
257
+ results.push({ name: skillName, updated: false, version: null, error: errorMessage });
258
+ }
259
+ // Clone failure doesn't produce install outcomes per-skill, but every
260
+ // checked skill already had `lastCheckedAt` updated in-memory above.
261
+ // Persist so those timestamps survive a gateway restart.
262
+ await this.persistState();
263
+ this.snapshot = this.buildSnapshot(this.snapshot.health, "manual-update-clone-failed");
264
+ return results;
265
+ }
266
+ let successCount = 0;
267
+ let failureCount = 0;
268
+ for (const skillName of skillsToInstall) {
269
+ const outcome = this.processInstallOutcome(skillName, installOutcomes);
270
+ if (outcome.success) {
271
+ results.push({ name: skillName, updated: true, version: outcome.version });
272
+ successCount += 1;
273
+ }
274
+ else {
275
+ results.push({ name: skillName, updated: false, version: null, error: outcome.error });
276
+ failureCount += 1;
277
+ }
278
+ }
279
+ await this.persistState();
280
+ logger.info(`[senpi-skills-manager] updateAll complete | installed=${successCount} failed=${failureCount} checked=${skillNames.length}`);
281
+ // Refresh snapshot so the status RPC reflects the freshly-installed
282
+ // versions immediately instead of waiting for the next poll tick.
283
+ this.snapshot = this.buildSnapshot("healthy", "manual-update-complete");
284
+ return results;
285
+ });
286
+ }
287
+ // ── Private scheduling ─────────────────────────────────────────────────────
288
+ scheduleNext(delayMs, token) {
289
+ if (this.stopped || token !== this.runToken)
290
+ return;
291
+ if (this.timer)
292
+ clearTimeout(this.timer);
293
+ const waitMs = Math.max(1_000, delayMs);
294
+ const nextCheckAt = new Date(Date.now() + waitMs).toISOString();
295
+ this.snapshot = { ...this.snapshot, nextCheckAt };
296
+ this.timer = setTimeout(() => {
297
+ void this.tick(token);
298
+ }, waitMs);
299
+ }
300
+ async tick(token) {
301
+ if (this.stopped || token !== this.runToken)
302
+ return;
303
+ await this.runSerialized("tick", async () => {
304
+ if (this.stopped || token !== this.runToken)
305
+ return;
306
+ this.options.logger.info("[senpi-skills-manager] tick started");
307
+ try {
308
+ await this.runTick();
309
+ await this.persistState();
310
+ this.snapshot = this.buildSnapshot("healthy", "tick-complete");
311
+ const nextDelayMs = this.options.config.pollIntervalMinutes * 60_000;
312
+ this.scheduleNext(nextDelayMs, token);
313
+ }
314
+ catch (err) {
315
+ this.options.logger.warn(`[senpi-skills-manager] tick error: ${formatErr(err)}`);
316
+ this.snapshot = this.buildSnapshot("degraded", "tick-error", formatErr(err));
317
+ // 5-minute backoff on full-tick failure (e.g. network completely down)
318
+ this.scheduleNext(5 * 60_000, token);
319
+ }
320
+ });
321
+ }
322
+ /**
323
+ * Run coordinator work in a global critical section so periodic ticks and
324
+ * manual RPC updates never interleave at await points.
325
+ */
326
+ async runSerialized(operationName, operation) {
327
+ const queued = this.operationChain;
328
+ let releaseQueue;
329
+ this.operationChain = new Promise((resolve) => {
330
+ releaseQueue = resolve;
331
+ });
332
+ await queued;
333
+ this.options.logger.info(`[senpi-skills-manager] runSerialized — entering critical section | op=${operationName}`);
334
+ try {
335
+ return await operation();
336
+ }
337
+ finally {
338
+ this.options.logger.info(`[senpi-skills-manager] runSerialized — leaving critical section | op=${operationName}`);
339
+ // Release the queue so the next caller can enter. We always release even
340
+ // if `operation` threw — otherwise a thrown error would deadlock every
341
+ // subsequent call forever.
342
+ releaseQueue?.();
343
+ }
344
+ }
345
+ /**
346
+ * Core tick logic: resolve skill names, check versions in parallel, apply
347
+ * policy per skill, then run a single clone for all skills needing update.
348
+ */
349
+ async runTick() {
350
+ const { logger, config } = this.options;
351
+ const skillNames = await this.resolveSkillNames();
352
+ if (skillNames.length === 0) {
353
+ logger.info("[senpi-skills-manager] tick — no skills to check");
354
+ return;
355
+ }
356
+ logger.info(`[senpi-skills-manager] tick — checking versions | count=${skillNames.length} skills=${skillNames.join(",")}`);
357
+ const checkVersionFn = this.resolveCheckFn();
358
+ // Check all versions in parallel — per-skill errors are captured, not thrown.
359
+ const versionChecks = await Promise.allSettled(skillNames.map(async (skillName) => {
360
+ const versionInfo = await checkVersionFn(config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
361
+ this.latestVersions.set(skillName, versionInfo.remoteVersion);
362
+ return { name: skillName, ...versionInfo };
363
+ }));
364
+ const skillsToInstall = [];
365
+ let majorPendingCount = 0;
366
+ let upToDateCount = 0;
367
+ for (let index = 0; index < versionChecks.length; index++) {
368
+ const skillName = skillNames[index];
369
+ const checkResult = versionChecks[index];
370
+ if (checkResult.status === "rejected") {
371
+ const errorMessage = formatErr(checkResult.reason);
372
+ this.recordCheckFailure(skillName, errorMessage);
373
+ logger.warn(`[senpi-skills-manager] tick — version check failed | skill=${skillName} err=${errorMessage}`);
374
+ continue;
375
+ }
376
+ const { localVersion, remoteVersion, updateKind } = checkResult.value;
377
+ const skillState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
378
+ skillState.lastCheckedAt = new Date().toISOString();
379
+ if (localVersion !== null)
380
+ skillState.installedVersion = localVersion;
381
+ // NOTE: do NOT clear failure state here. A successful version check does
382
+ // not prove the skill is operationally healthy — it only proves the raw
383
+ // SKILL.md is reachable. Install failures (clone, copy, swap) reflect
384
+ // real operational issues; clearing them on a check-success would wipe
385
+ // that history mid-tick and prevent consecutiveFailures from accumulating
386
+ // across multiple failed install attempts. Failure state is cleared only
387
+ // by `recordSuccess` (called after a successful install).
388
+ logger.info(`[senpi-skills-manager] tick — skill=${skillName} local=${localVersion ?? "none"} remote=${remoteVersion ?? "unknown"} kind=${updateKind}`);
389
+ if (updateKind === "none" || updateKind === "unknown") {
390
+ this.state.skills[skillName] = skillState;
391
+ upToDateCount += 1;
392
+ continue;
393
+ }
394
+ if (updateKind === "major") {
395
+ // Major versions are never auto-applied — notify the user and let them decide.
396
+ await this.handleMajorAvailable(skillState, skillName, remoteVersion);
397
+ this.state.skills[skillName] = skillState;
398
+ majorPendingCount += 1;
399
+ continue;
400
+ }
401
+ // patch / minor — queue for install
402
+ skillsToInstall.push(skillName);
403
+ this.state.skills[skillName] = skillState;
404
+ }
405
+ logger.info(`[senpi-skills-manager] tick — triage | upToDate=${upToDateCount} majorPending=${majorPendingCount} toInstall=${skillsToInstall.length}`);
406
+ if (skillsToInstall.length === 0)
407
+ return;
408
+ await this.applyUpdates(skillsToInstall);
409
+ }
410
+ /**
411
+ * Clone the repo once and install every skill in `skillNames`.
412
+ * Per-skill errors are recorded in state without aborting the others.
413
+ * The update summary notification distinguishes successful installs from
414
+ * failures so the user can see exactly which skills need attention.
415
+ *
416
+ * If the clone itself fails we log and return without touching state or
417
+ * sending a summary — a full-clone failure is almost always a transient
418
+ * network/git issue that affects every skill equally, so attributing it
419
+ * per-skill would be misleading. The next tick retries.
420
+ */
421
+ async applyUpdates(skillNames) {
422
+ const { logger } = this.options;
423
+ logger.info(`[senpi-skills-manager] applyUpdates — batch install starting | count=${skillNames.length} skills=${skillNames.join(",")}`);
424
+ let installOutcomes;
425
+ try {
426
+ installOutcomes = await this.cloneAndInstallMany(skillNames);
427
+ }
428
+ catch (err) {
429
+ logger.warn(`[senpi-skills-manager] applyUpdates — git clone failed, skipping batch: ${formatErr(err)}`);
430
+ return;
431
+ }
432
+ const succeededSkillNames = [];
433
+ const failedSkills = [];
434
+ for (const skillName of skillNames) {
435
+ const outcome = this.processInstallOutcome(skillName, installOutcomes);
436
+ if (outcome.success) {
437
+ succeededSkillNames.push(skillName);
438
+ logger.info(`[senpi-skills-manager] applyUpdates — installed | skill=${skillName} version=${outcome.version ?? "unknown"}`);
439
+ }
440
+ else {
441
+ failedSkills.push({ name: skillName, reason: outcome.error });
442
+ }
443
+ }
444
+ logger.info(`[senpi-skills-manager] applyUpdates complete | installed=${succeededSkillNames.length} failed=${failedSkills.length}`);
445
+ await this.sendUpdateSummary(succeededSkillNames, failedSkills);
446
+ }
447
+ /**
448
+ * Clone the remote skills repo exactly once and install every skill in
449
+ * `skillNames` from that single checkout. Returns a per-skill outcome map:
450
+ * `{ version }` on success or `{ version: null, error }` on install failure.
451
+ *
452
+ * Throws only when the clone itself fails — per-skill install failures are
453
+ * captured in the map so callers can continue processing the rest.
454
+ *
455
+ * When `deps.cloneAndInstall` is supplied (unit tests), that injector is
456
+ * called in place of the real clone+install loop; any names missing from
457
+ * its returned map are treated as install failures.
458
+ */
459
+ async cloneAndInstallMany(skillNames) {
460
+ const { logger, config } = this.options;
461
+ const outcomes = new Map();
462
+ if (skillNames.length === 0)
463
+ return outcomes;
464
+ if (this.options.deps?.cloneAndInstall) {
465
+ // Injected dep for tests — receives skill names + config, returns name→version map.
466
+ // Names absent from the returned map are treated as failures.
467
+ logger.info(`[senpi-skills-manager] cloneAndInstallMany — using injected dep | skills=${skillNames.join(",")}`);
468
+ const versionsByName = await this.options.deps.cloneAndInstall(skillNames, config);
469
+ for (const skillName of skillNames) {
470
+ if (versionsByName.has(skillName)) {
471
+ outcomes.set(skillName, {
472
+ version: versionsByName.get(skillName) ?? this.latestVersions.get(skillName) ?? null,
473
+ });
474
+ }
475
+ else {
476
+ outcomes.set(skillName, { version: null, error: "install failed" });
477
+ }
478
+ }
479
+ return outcomes;
480
+ }
481
+ logger.info(`[senpi-skills-manager] cloneAndInstallMany — cloning repo | url=${SKILLS_REPO_URL} timeoutMs=120000`);
482
+ const { cloneDir, cleanup } = await cloneSkillsRepo(SKILLS_REPO_URL, 120_000, logger);
483
+ logger.info(`[senpi-skills-manager] cloneAndInstallMany — clone ready, installing | count=${skillNames.length} cloneDir=${cloneDir}`);
484
+ try {
485
+ for (const skillName of skillNames) {
486
+ try {
487
+ await installSkill(cloneDir, skillName, config.skillsDir, logger);
488
+ const newVersion = this.latestVersions.get(skillName) ?? null;
489
+ outcomes.set(skillName, { version: newVersion });
490
+ logger.info(`[senpi-skills-manager] cloneAndInstallMany — installed | skill=${skillName} version=${newVersion ?? "unknown"}`);
491
+ }
492
+ catch (err) {
493
+ const errorMessage = formatErr(err);
494
+ outcomes.set(skillName, { version: null, error: errorMessage });
495
+ logger.warn(`[senpi-skills-manager] cloneAndInstallMany — install failed | skill=${skillName} err=${errorMessage}`);
496
+ }
497
+ }
498
+ }
499
+ finally {
500
+ await cleanup();
501
+ logger.info("[senpi-skills-manager] cloneAndInstallMany — clone cleanup done");
502
+ }
503
+ return outcomes;
504
+ }
505
+ /**
506
+ * Record a successful install on in-memory skill state.
507
+ *
508
+ * Resets failure tracking — a successful end-to-end install is the only
509
+ * signal we treat as "operationally healthy." A successful version check
510
+ * alone is NOT sufficient (and intentionally does not call this), because:
511
+ *
512
+ * - The version check only proves the raw SKILL.md is reachable. It does
513
+ * not exercise the clone/copy/swap pipeline that actually keeps the skill
514
+ * working.
515
+ * - Clearing failure state on every check-success would wipe install-failure
516
+ * history mid-tick: a skill failing to install on every tick would still
517
+ * report `consecutiveFailures: 1` (instead of N) because each tick's
518
+ * check-success reset would precede the install-failure increment.
519
+ *
520
+ * Consequence: a skill that's perpetually up-to-date but had a one-off
521
+ * failure in the past will retain a non-zero `consecutiveFailures` until
522
+ * the next install. We accept that trade-off — surfacing a stale failure
523
+ * counter is preferable to silently wiping real install-failure history.
524
+ */
525
+ recordSuccess(skillState, newVersion) {
526
+ skillState.installedVersion = newVersion;
527
+ skillState.lastAppliedVersion = newVersion;
528
+ skillState.consecutiveFailures = 0;
529
+ skillState.lastFailureAt = null;
530
+ skillState.lastFailureReason = null;
531
+ }
532
+ /**
533
+ * Record a version-check failure on in-memory skill state.
534
+ *
535
+ * Shared by `runTick` and `updateAll` — both need to bump
536
+ * `consecutiveFailures` and capture the reason when `Promise.allSettled`
537
+ * surfaces a rejected check. `lastCheckedAt` is updated too so the snapshot
538
+ * doesn't look like the skill was never polled (it was — the poll failed).
539
+ */
540
+ recordCheckFailure(skillName, errorMessage) {
541
+ const skillState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
542
+ const now = new Date().toISOString();
543
+ skillState.lastCheckedAt = now;
544
+ skillState.consecutiveFailures += 1;
545
+ skillState.lastFailureAt = now;
546
+ skillState.lastFailureReason = errorMessage;
547
+ this.state.skills[skillName] = skillState;
548
+ }
549
+ /**
550
+ * Apply one install outcome to in-memory skill state and return a compact
551
+ * success/failure result for the caller's bookkeeping.
552
+ */
553
+ processInstallOutcome(skillName, installOutcomes) {
554
+ const outcome = installOutcomes.get(skillName) ?? { version: null, error: "install result missing" };
555
+ const skillState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
556
+ if (outcome.error) {
557
+ skillState.consecutiveFailures += 1;
558
+ skillState.lastFailureAt = new Date().toISOString();
559
+ skillState.lastFailureReason = outcome.error;
560
+ this.state.skills[skillName] = skillState;
561
+ return { success: false, error: outcome.error };
562
+ }
563
+ this.recordSuccess(skillState, outcome.version);
564
+ this.state.skills[skillName] = skillState;
565
+ return { success: true, version: outcome.version };
566
+ }
567
+ /**
568
+ * Apply a single skill update (used by the manual `updateSkill` RPC path).
569
+ * Returns the installed version.
570
+ *
571
+ * Delegates to {@link cloneAndInstallMany} so the single-skill and batch
572
+ * paths share one implementation of clone+install. The per-skill install
573
+ * error is re-thrown here so `updateSkill` can surface it to the RPC
574
+ * caller via a rejected promise (batch path captures it instead).
575
+ */
576
+ async applyUpdate(skillName, remoteVersion) {
577
+ const { logger } = this.options;
578
+ logger.info(`[senpi-skills-manager] applyUpdate — starting | skill=${skillName} targetVersion=${remoteVersion ?? "unknown"}`);
579
+ // Clone can throw (full repo fetch failed). Unlike the batch path — which
580
+ // intentionally doesn't attribute a shared clone failure to individual
581
+ // skills — the single-skill path has an unambiguous owner, so we record
582
+ // the clone failure against this skill's failure counters before
583
+ // re-throwing to the RPC caller.
584
+ let installOutcomes;
585
+ try {
586
+ installOutcomes = await this.cloneAndInstallMany([skillName]);
587
+ }
588
+ catch (err) {
589
+ const errorMessage = `clone failed: ${formatErr(err)}`;
590
+ const skillState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
591
+ skillState.consecutiveFailures += 1;
592
+ const now = new Date().toISOString();
593
+ skillState.lastFailureAt = now;
594
+ skillState.lastFailureReason = errorMessage;
595
+ this.state.skills[skillName] = skillState;
596
+ await this.persistState();
597
+ logger.warn(`[senpi-skills-manager] applyUpdate — ${errorMessage} | skill=${skillName}`);
598
+ throw err;
599
+ }
600
+ // Delegate outcome bookkeeping (success or install-failure) to the shared
601
+ // helper so the single-skill path records failure state identically to
602
+ // the batch path — previously this path threw raw without ever touching
603
+ // consecutiveFailures / lastFailureAt / lastFailureReason, so manual
604
+ // updateSkill install failures were silently invisible to the snapshot.
605
+ const processed = this.processInstallOutcome(skillName, installOutcomes);
606
+ await this.persistState();
607
+ if (!processed.success) {
608
+ logger.warn(`[senpi-skills-manager] applyUpdate — install failed | skill=${skillName} err=${processed.error}`);
609
+ throw new Error(processed.error);
610
+ }
611
+ const installedVersion = processed.version ?? remoteVersion ?? null;
612
+ logger.info(`[senpi-skills-manager] applyUpdate complete | skill=${skillName} version=${installedVersion ?? "unknown"}`);
613
+ return installedVersion;
614
+ }
615
+ /**
616
+ * Send a one-way Telegram notification for a pending major update, deduped
617
+ * by version.
618
+ *
619
+ * The dedup key (`lastNotifiedMajorVersion`) is only set after the notify
620
+ * call resolves successfully. If the notification fails (Telegram API down,
621
+ * transient network error, etc.) the key is left unchanged so the next tick
622
+ * retries delivery instead of silently suppressing it forever.
623
+ */
624
+ async handleMajorAvailable(skillState, skillName, remoteVersion) {
625
+ if (skillState.lastNotifiedMajorVersion === remoteVersion) {
626
+ this.options.logger.info(`[senpi-skills-manager] handleMajorAvailable — notification already sent, skipping | skill=${skillName} version=${remoteVersion}`);
627
+ return;
628
+ }
629
+ try {
630
+ await this.options.notify(`Senpi skill "${skillName}" has a major update available: v${remoteVersion}.\n` +
631
+ `Major updates are not applied automatically.\n` +
632
+ `Run: openclaw senpi skills update ${skillName}`);
633
+ skillState.lastNotifiedMajorVersion = remoteVersion;
634
+ this.options.logger.info(`[senpi-skills-manager] handleMajorAvailable — major-update notification sent | skill=${skillName} version=${remoteVersion}`);
635
+ }
636
+ catch (err) {
637
+ this.options.logger.warn(`[senpi-skills-manager] failed to send major-update notification for "${skillName}": ${formatErr(err)}`);
638
+ // Leave dedup key unchanged — retry on next tick.
639
+ }
640
+ }
641
+ /**
642
+ * Send an aggregated notification summarising the outcome of this tick's
643
+ * updates. Successful installs and failures are shown in separate sections
644
+ * so the user can tell which skills still need attention. Sections with no
645
+ * entries are omitted. If neither section has entries, no notification is
646
+ * sent.
647
+ */
648
+ async sendUpdateSummary(succeededSkillNames, failedSkills) {
649
+ if (succeededSkillNames.length === 0 && failedSkills.length === 0)
650
+ return;
651
+ const sections = [];
652
+ if (succeededSkillNames.length > 0) {
653
+ const successLines = succeededSkillNames.map((name) => {
654
+ const latestVersion = this.latestVersions.get(name);
655
+ return ` • ${name}${latestVersion ? ` -> v${latestVersion}` : ""}`;
656
+ });
657
+ sections.push(`Updated:\n${successLines.join("\n")}`);
658
+ }
659
+ if (failedSkills.length > 0) {
660
+ const failureLines = failedSkills.map(({ name, reason }) => ` • ${name}: ${reason}`);
661
+ sections.push(`Failed to update:\n${failureLines.join("\n")}`);
662
+ }
663
+ try {
664
+ await this.options.notify(`Senpi skills update:\n\n${sections.join("\n\n")}`);
665
+ this.options.logger.info(`[senpi-skills-manager] sendUpdateSummary — notification sent | succeeded=${succeededSkillNames.length} failed=${failedSkills.length}`);
666
+ }
667
+ catch (err) {
668
+ this.options.logger.warn(`[senpi-skills-manager] failed to send update summary notification: ${formatErr(err)}`);
669
+ }
670
+ }
671
+ /**
672
+ * Return the version-check function for this coordinator: either the
673
+ * injected `deps.checkSkillVersion` (unit tests) or the real
674
+ * `checkSkillVersion` with the default `fetchImpl`.
675
+ *
676
+ * Extracted so the three call sites (`updateSkill`, `updateAll`, `runTick`)
677
+ * don't repeat the same resolve-and-wrap lambda — a change to the real
678
+ * `checkSkillVersion` signature now only has to land here.
679
+ */
680
+ resolveCheckFn() {
681
+ return (this.options.deps?.checkSkillVersion ??
682
+ ((skillsDir, skillName, rawBaseUrl, logger) => checkSkillVersion(skillsDir, skillName, rawBaseUrl, undefined, logger)));
683
+ }
684
+ /**
685
+ * Resolve the list of skill names to check this tick.
686
+ * Uses `config.skills` when non-empty; otherwise discovers all immediate
687
+ * subdirectories of `skillsDir`.
688
+ */
689
+ async resolveSkillNames() {
690
+ if (this.options.config.skills.length > 0) {
691
+ return this.options.config.skills;
692
+ }
693
+ try {
694
+ const entries = await readdir(this.options.config.skillsDir, { withFileTypes: true });
695
+ return entries
696
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
697
+ .map((entry) => entry.name)
698
+ .sort();
699
+ }
700
+ catch (err) {
701
+ // Missing / unreadable skillsDir — log so the operator can diagnose
702
+ // (previously failed silently, which made "no skills checked" indistinguishable
703
+ // from "skillsDir configured wrong" in the logs).
704
+ this.options.logger.warn(`[senpi-skills-manager] resolveSkillNames — failed to read skillsDir | dir=${this.options.config.skillsDir} err=${formatErr(err)}`);
705
+ return [];
706
+ }
707
+ }
708
+ /**
709
+ * Build the point-in-time snapshot returned by {@link getSnapshot}.
710
+ *
711
+ * The snapshot is the single serialisable view of the coordinator's state
712
+ * consumed by gateway RPCs (`senpi.skills.status`) and any health UI. It
713
+ * fuses three data sources:
714
+ * 1. Per-skill persisted state (`this.state.skills`) — installed version,
715
+ * last check time, consecutive failures, last failure reason.
716
+ * 2. In-memory `this.latestVersions` map — the most recent remote version
717
+ * observed during a version check (may not yet be installed).
718
+ * 3. Caller-supplied lifecycle metadata — `health`, `lastAction`,
719
+ * `lastError` describing the most recent tick / manual RPC outcome.
720
+ *
721
+ * `nextCheckAt` is preserved from the previous snapshot so callers always
722
+ * see a valid ETA even on transient rebuilds (e.g. mid-tick error paths).
723
+ *
724
+ * This is called at every state-mutating boundary (start, tick complete,
725
+ * tick error, manual update complete, manual update skipped, manual update
726
+ * clone failed, stop/disable) so the next RPC response reflects the latest
727
+ * state rather than stale pre-action data.
728
+ */
729
+ buildSnapshot(health, lastAction, lastError = null) {
730
+ const skillSnapshots = Object.values(this.state.skills).map((skillState) => ({
731
+ name: skillState.name,
732
+ installedVersion: skillState.installedVersion,
733
+ latestVersion: this.latestVersions.get(skillState.name) ?? null,
734
+ lastCheckedAt: skillState.lastCheckedAt,
735
+ consecutiveFailures: skillState.consecutiveFailures,
736
+ lastFailureReason: skillState.lastFailureReason,
737
+ }));
738
+ return {
739
+ enabled: this.options.config.enabled,
740
+ skillsDir: this.options.config.skillsDir,
741
+ health,
742
+ lastAction,
743
+ lastError,
744
+ skills: skillSnapshots,
745
+ nextCheckAt: this.snapshot?.nextCheckAt ?? null,
746
+ };
747
+ }
748
+ async persistState() {
749
+ try {
750
+ await this.store.save(this.state);
751
+ }
752
+ catch (err) {
753
+ this.options.logger.warn(`[senpi-skills-manager] failed to persist state: ${formatErr(err)}`);
754
+ }
755
+ }
756
+ }
757
+ //# sourceMappingURL=coordinator.js.map