@stage-labs/metro 0.1.0-beta.279 → 0.1.0-beta.280

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ import { readFileSync, lstatSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { log } from '@metro-labs/core/log';
4
+ import { writeSecure } from '@metro-labs/core/secure-fs';
5
+ import { isRecord } from '@metro-labs/core/is-record';
6
+ import { SKILL_MANIFEST_MAX, type SkillActivation, type SkillGeneration } from '@metro-labs/core/skill-source';
7
+ import { currentSkills, localSkillNames, pendingSkills, prepareSkillsRoot, projectionKey, projectSkills, pruneSkills, switchSkills, type LocalAliases } from './skills-files.js';
8
+
9
+ interface SkillHooks {
10
+ safe(): boolean;
11
+ reload(): Promise<unknown>;
12
+ wake(): void;
13
+ }
14
+ const INITIAL: SkillActivation = { generation: null, loading: null, appliedAt: null, problem: null, shadowed: [] };
15
+ class ReloadTimeout extends Error {}
16
+
17
+ function savedStatus(root: string): unknown {
18
+ const path = join(root, 'status.json');
19
+ try {
20
+ if (lstatSync(path).size > SKILL_MANIFEST_MAX) throw new Error('Invalid skills status.');
21
+ return JSON.parse(readFileSync(path, 'utf8')) as unknown;
22
+ } catch (err) { if (isRecord(err) && err.code === 'ENOENT') return null; throw err; }
23
+ }
24
+
25
+ function repairInterrupted(root: string, raw: unknown): void {
26
+ if (!isRecord(raw) || typeof raw.loading !== 'string') return;
27
+ if (raw.rollback !== null && typeof raw.rollback !== 'string') throw new Error('Missing skills rollback target.');
28
+ switchSkills(root, raw.rollback);
29
+ }
30
+
31
+ function previousStatus(root: string): SkillActivation {
32
+ const raw = savedStatus(root);
33
+ repairInterrupted(root, raw);
34
+ const id = currentSkills(root)?.split('/')[2];
35
+ if (id === undefined) return { ...INITIAL };
36
+ if (!isRecord(raw) || raw.generation !== id || typeof raw.appliedAt !== 'string' || !Array.isArray(raw.shadowed)) throw new Error('Skills activation was interrupted.');
37
+ return { generation: id, appliedAt: raw.appliedAt, loading: null, problem: null, shadowed: raw.shadowed.filter((name): name is string => typeof name === 'string').slice(0, 64) };
38
+ }
39
+
40
+ async function reload(hooks: SkillHooks, timeout: number): Promise<void> {
41
+ let timer: ReturnType<typeof setTimeout> | undefined;
42
+ try {
43
+ await Promise.race([hooks.reload(), new Promise<never>((_resolve, reject) => {
44
+ timer = setTimeout(() => { reject(new ReloadTimeout('Skill reload timed out.')); }, timeout);
45
+ })]);
46
+ } finally { clearTimeout(timer); }
47
+ }
48
+
49
+ export class SkillsRefresh {
50
+ private hooks: SkillHooks | null = null;
51
+ private timer: ReturnType<typeof setInterval> | null = null;
52
+ private readonly aliases: LocalAliases = new Map();
53
+ private key: string | null = null;
54
+ private failed: string | null = null;
55
+ private closed = false;
56
+ private prunedAt = 0;
57
+ private status: SkillActivation;
58
+ busy = false;
59
+
60
+ constructor(readonly root: string, private readonly claude: string, private readonly timeout = 20_000) {
61
+ prepareSkillsRoot(root);
62
+ this.status = previousStatus(root);
63
+ this.save();
64
+ }
65
+
66
+ start(hooks: SkillHooks): void {
67
+ this.hooks = hooks;
68
+ this.check();
69
+ this.timer = setInterval(() => { this.check(); }, 1000);
70
+ this.timer.unref();
71
+ }
72
+
73
+ close(): void {
74
+ this.closed = true;
75
+ if (this.timer !== null) clearInterval(this.timer);
76
+ this.timer = null;
77
+ }
78
+
79
+ check(): void {
80
+ if (this.closed || this.busy || this.hooks === null || !this.hooks.safe()) return;
81
+ try {
82
+ const generation = pendingSkills(this.root);
83
+ if (generation === null) return;
84
+ const locals = generation.skills.length === 0 ? [] : localSkillNames(this.claude, this.aliases);
85
+ const key = projectionKey(generation, locals);
86
+ if (this.unchanged(key)) return;
87
+ this.busy = true;
88
+ this.apply(generation, locals, key, this.hooks).catch(() => { this.pause(); });
89
+ } catch {
90
+ this.status.problem = 'The managed skills files are unreadable. The loaded skills are kept.';
91
+ try { this.save(); } catch { this.pause(); }
92
+ }
93
+ }
94
+
95
+ private unchanged(key: string): boolean {
96
+ if (key !== this.key && key !== this.failed) return false;
97
+ if (Date.now() - this.prunedAt > 60_000) {
98
+ const active = currentSkills(this.root)?.split('/')[2] ?? '';
99
+ try { pruneSkills(this.root, new Set([active])); } catch { log.warn('sdk-runner: old managed skills could not be removed'); }
100
+ this.prunedAt = Date.now();
101
+ }
102
+ return true;
103
+ }
104
+
105
+ private save(): void { writeSecure(join(this.root, 'status.json'), JSON.stringify(this.status)); }
106
+
107
+ private pause(): void {
108
+ this.busy = true;
109
+ this.status.problem = 'Skill reload could not finish safely. Input is paused. Check the files, then stop and start the Agent SDK.';
110
+ try { this.save(); } catch { log.error('sdk-runner: skills state could not be saved; input remains paused'); }
111
+ }
112
+
113
+ private async recover(before: string | null, previous: SkillActivation, hooks: SkillHooks, err: unknown): Promise<void> {
114
+ switchSkills(this.root, before);
115
+ if (err instanceof ReloadTimeout) { this.pause(); return; }
116
+ try {
117
+ await reload(hooks, this.timeout);
118
+ if (this.closed) return;
119
+ this.status = { ...previous, loading: null, problem: 'Skill reload failed. The previous skills were restored. Sync again, or remove the source again to retry unloading.' };
120
+ this.save();
121
+ this.busy = false;
122
+ } catch { this.pause(); }
123
+ }
124
+
125
+ private async apply(generation: SkillGeneration, locals: string[], key: string, hooks: SkillHooks): Promise<void> {
126
+ const before = currentSkills(this.root);
127
+ const previous = { ...this.status };
128
+ this.status = { ...previous, loading: generation.id, rollback: before, problem: null };
129
+ this.save();
130
+ try {
131
+ const projection = projectSkills(this.root, generation, locals);
132
+ switchSkills(this.root, projection.target);
133
+ await reload(hooks, this.timeout);
134
+ if (this.closed) return;
135
+ this.key = key;
136
+ this.failed = null;
137
+ this.status = { generation: generation.id, loading: null, appliedAt: new Date().toISOString(), problem: null, shadowed: projection.shadowed };
138
+ this.save();
139
+ this.busy = false;
140
+ const keep = new Set([generation.id]);
141
+ if (before !== null && generation.source !== null) keep.add(before.split('/')[2] ?? '');
142
+ try { pruneSkills(this.root, keep); } catch { log.warn('sdk-runner: old managed skills could not be removed'); }
143
+ } catch (err) {
144
+ if (this.closed) return;
145
+ this.failed = key;
146
+ await this.recover(before, previous, hooks, err);
147
+ }
148
+ hooks.wake();
149
+ }
150
+ }