pi-harness-runtime 1.1.18 → 1.1.26
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.
- package/harness/e2e/auto-test-loop.ts +94 -29
- package/harness/e2e/tools-detector.ts +20 -17
- package/harness/index.ts +20 -3
- package/harness/loop-runtime.ts +49 -13
- package/harness/skill-sync.test.ts +57 -0
- package/harness/skill-sync.ts +247 -0
- package/harness/ssh-hang-recovery.ts +280 -0
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +28 -0
- package/packages/skill-mcp-client/src/client.ts +171 -0
- package/packages/skill-mcp-client/src/errors.ts +75 -0
- package/packages/skill-mcp-client/src/index.ts +13 -0
- package/packages/skill-mcp-client/src/types.ts +123 -0
- package/packages/skill-registry/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Sync Service — RFC-0106
|
|
3
|
+
*
|
|
4
|
+
* Syncs skills from Skills SaaS backend and registers them with local registry.
|
|
5
|
+
* Falls back gracefully if backend is unavailable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import type {
|
|
12
|
+
SkillInfo,
|
|
13
|
+
GetSkillResult,
|
|
14
|
+
} from "../packages/skill-mcp-client/src/types.js";
|
|
15
|
+
import { SkillAPIError } from "../packages/skill-mcp-client/src/errors.js";
|
|
16
|
+
|
|
17
|
+
type SkillMCPClient = InstanceType<
|
|
18
|
+
typeof import("../packages/skill-mcp-client/src/client.js").SkillMCPClient
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
// Lazy import to avoid errors if package not built yet
|
|
22
|
+
let clientInstance: SkillMCPClient | null = null;
|
|
23
|
+
|
|
24
|
+
async function getClient(): Promise<SkillMCPClient | null> {
|
|
25
|
+
if (!process.env.SKILLS_SAAS_API_KEY) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (clientInstance) {
|
|
30
|
+
return clientInstance;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const mod = await import("../packages/skill-mcp-client/src/client.js");
|
|
35
|
+
const SkillMCPClientClass = mod.SkillMCPClient;
|
|
36
|
+
const serverUrl =
|
|
37
|
+
process.env.SKILLS_SAAS_URL || "https://api.skills.bunchee.online";
|
|
38
|
+
|
|
39
|
+
clientInstance = new SkillMCPClientClass({
|
|
40
|
+
serverUrl,
|
|
41
|
+
apiKey: process.env.SKILLS_SAAS_API_KEY,
|
|
42
|
+
timeoutMs: 5000,
|
|
43
|
+
});
|
|
44
|
+
return clientInstance;
|
|
45
|
+
} catch {
|
|
46
|
+
console.warn("[skill-sync] Failed to import skill-mcp-client");
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Cache directory
|
|
52
|
+
const CACHE_DIR = join(homedir(), ".pi", "skills-cache");
|
|
53
|
+
const CACHE_FILE = join(CACHE_DIR, "skills-cache.json");
|
|
54
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
55
|
+
|
|
56
|
+
interface CacheData {
|
|
57
|
+
skills: SkillInfo[];
|
|
58
|
+
cachedAt: string;
|
|
59
|
+
version: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface SkillSyncResult {
|
|
63
|
+
success: boolean;
|
|
64
|
+
skillCount: number;
|
|
65
|
+
source: "saas" | "cache" | "none";
|
|
66
|
+
error?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get cached skills from local disk
|
|
71
|
+
*/
|
|
72
|
+
function getCachedSkills(): CacheData | null {
|
|
73
|
+
try {
|
|
74
|
+
if (!existsSync(CACHE_FILE)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const data = JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as CacheData;
|
|
79
|
+
const age = Date.now() - new Date(data.cachedAt).getTime();
|
|
80
|
+
|
|
81
|
+
if (age > CACHE_TTL_MS) {
|
|
82
|
+
return null; // Cache expired
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return data;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Save skills to local cache
|
|
93
|
+
*/
|
|
94
|
+
function saveCache(data: CacheData): void {
|
|
95
|
+
try {
|
|
96
|
+
if (!existsSync(CACHE_DIR)) {
|
|
97
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
98
|
+
}
|
|
99
|
+
writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
|
|
100
|
+
} catch {
|
|
101
|
+
console.warn("[skill-sync] Failed to save cache");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fetch and cache skills from SaaS backend
|
|
107
|
+
*/
|
|
108
|
+
async function fetchFromSaaS(): Promise<CacheData | null> {
|
|
109
|
+
const client = await getClient();
|
|
110
|
+
if (!client) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const result = await client.listSkills({ limit: 100 });
|
|
116
|
+
const cacheData: CacheData = {
|
|
117
|
+
skills: result.skills,
|
|
118
|
+
cachedAt: new Date().toISOString(),
|
|
119
|
+
version: "1.0.0",
|
|
120
|
+
};
|
|
121
|
+
saveCache(cacheData);
|
|
122
|
+
return cacheData;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error instanceof SkillAPIError) {
|
|
125
|
+
console.warn(
|
|
126
|
+
`[skill-sync] API error: ${error.message} (code: ${error.code})`,
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
console.warn("[skill-sync] Failed to fetch from SaaS");
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Sync skills from SaaS backend (RFC-0106)
|
|
137
|
+
*
|
|
138
|
+
* Flow:
|
|
139
|
+
* 1. Check cache first
|
|
140
|
+
* 2. If cache valid, return cached
|
|
141
|
+
* 3. If cache expired or missing, fetch from SaaS
|
|
142
|
+
* 4. If SaaS fails, return empty array (graceful degradation)
|
|
143
|
+
*
|
|
144
|
+
* @param options.forceRefresh - Force refresh from SaaS
|
|
145
|
+
* @returns Skills list (may be empty if backend unavailable)
|
|
146
|
+
*/
|
|
147
|
+
export async function syncSkillsFromSaaS(
|
|
148
|
+
options: { forceRefresh?: boolean } = {},
|
|
149
|
+
): Promise<SkillSyncResult> {
|
|
150
|
+
// Check if API key is configured
|
|
151
|
+
if (!process.env.SKILLS_SAAS_API_KEY) {
|
|
152
|
+
return {
|
|
153
|
+
success: true,
|
|
154
|
+
skillCount: 0,
|
|
155
|
+
source: "none",
|
|
156
|
+
error: "SKILLS_SAAS_API_KEY not configured",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Check cache first (unless force refresh)
|
|
161
|
+
if (!options.forceRefresh) {
|
|
162
|
+
const cached = getCachedSkills();
|
|
163
|
+
if (cached) {
|
|
164
|
+
return {
|
|
165
|
+
success: true,
|
|
166
|
+
skillCount: cached.skills.length,
|
|
167
|
+
source: "cache",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Fetch from SaaS
|
|
173
|
+
const data = await fetchFromSaaS();
|
|
174
|
+
if (data) {
|
|
175
|
+
return {
|
|
176
|
+
success: true,
|
|
177
|
+
skillCount: data.skills.length,
|
|
178
|
+
source: "saas",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// SaaS failed, try to use stale cache
|
|
183
|
+
const staleCache = getCachedSkills();
|
|
184
|
+
if (staleCache) {
|
|
185
|
+
return {
|
|
186
|
+
success: true,
|
|
187
|
+
skillCount: staleCache.skills.length,
|
|
188
|
+
source: "cache",
|
|
189
|
+
error: "Using stale cache (SaaS unavailable)",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Everything failed, return empty
|
|
194
|
+
return {
|
|
195
|
+
success: true,
|
|
196
|
+
skillCount: 0,
|
|
197
|
+
source: "none",
|
|
198
|
+
error: "Could not reach Skills SaaS backend",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get full skill prompt by slug or id
|
|
204
|
+
*
|
|
205
|
+
* Returns null if skill not found or backend unavailable
|
|
206
|
+
*/
|
|
207
|
+
export async function getSkillFromSaaS(identifier: {
|
|
208
|
+
slug?: string;
|
|
209
|
+
id?: string;
|
|
210
|
+
}): Promise<GetSkillResult | null> {
|
|
211
|
+
const client = await getClient();
|
|
212
|
+
if (!client) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
return await client.getSkill(identifier);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error instanceof SkillAPIError) {
|
|
220
|
+
console.warn(`[skill-sync] Failed to get skill: ${error.message}`);
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Check if skill sync is configured
|
|
228
|
+
*/
|
|
229
|
+
export function isSkillSyncConfigured(): boolean {
|
|
230
|
+
return !!process.env.SKILLS_SAAS_API_KEY;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get sync status info
|
|
235
|
+
*/
|
|
236
|
+
export function getSyncStatus(): {
|
|
237
|
+
configured: boolean;
|
|
238
|
+
cacheValid: boolean;
|
|
239
|
+
cachedSkills: number;
|
|
240
|
+
} {
|
|
241
|
+
const cache = getCachedSkills();
|
|
242
|
+
return {
|
|
243
|
+
configured: isSkillSyncConfigured(),
|
|
244
|
+
cacheValid: !!cache,
|
|
245
|
+
cachedSkills: cache?.skills.length ?? 0,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Hang Recovery — Smart SSH wrapper that auto-recovers from hangs
|
|
3
|
+
*
|
|
4
|
+
* Problem: SSH commands can hang indefinitely (PM2 restart, sudo prompts, etc.)
|
|
5
|
+
* Solution: Timeout + process killing + retry + escalation
|
|
6
|
+
*
|
|
7
|
+
* Flow:
|
|
8
|
+
* Execute SSH command
|
|
9
|
+
* → Timeout reached? → Kill stuck processes → Retry
|
|
10
|
+
* → Retry failed? → Kill PM2 daemon processes → Retry
|
|
11
|
+
* → Still failing? → Escalate to human with diagnostic
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execSync, exec } from "child_process";
|
|
15
|
+
import { setTimeout as setAsyncTimeout, clearTimeout } from "timers";
|
|
16
|
+
|
|
17
|
+
export interface SSHConfig {
|
|
18
|
+
host: string;
|
|
19
|
+
user?: string;
|
|
20
|
+
timeout?: number; // ms, default 120000 (2 min)
|
|
21
|
+
maxRetries?: number;
|
|
22
|
+
strictHostKeyChecking?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SSHResult {
|
|
26
|
+
success: boolean;
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
elapsedMs: number;
|
|
30
|
+
retries: number;
|
|
31
|
+
hungProcessesKilled: boolean;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_TIMEOUT = 120_000; // 2 minutes
|
|
36
|
+
const HANG_THRESHOLD_MS = 60_000; // 1 minute = likely hang
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sanitize input for shell commands - prevent command injection
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Execute SSH command with automatic hang recovery
|
|
44
|
+
*/
|
|
45
|
+
export async function sshWithRecovery(
|
|
46
|
+
config: SSHConfig,
|
|
47
|
+
command: string,
|
|
48
|
+
): Promise<SSHResult> {
|
|
49
|
+
const {
|
|
50
|
+
host,
|
|
51
|
+
user = "frappe",
|
|
52
|
+
timeout = DEFAULT_TIMEOUT,
|
|
53
|
+
maxRetries = 3,
|
|
54
|
+
strictHostKeyChecking = false,
|
|
55
|
+
} = config;
|
|
56
|
+
|
|
57
|
+
const sshBase = `ssh -o BatchMode=yes -o ConnectTimeout=30 -o StrictHostKeyChecking=${strictHostKeyChecking ? "yes" : "no"} ${user}@${host}`;
|
|
58
|
+
|
|
59
|
+
let retries = 0;
|
|
60
|
+
let hungProcessesKilled = false;
|
|
61
|
+
|
|
62
|
+
while (retries <= maxRetries) {
|
|
63
|
+
const startTime = Date.now();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// Execute with timeout
|
|
67
|
+
const result = await execAsync(
|
|
68
|
+
`${sshBase} '${command.replace(/'/g, "'\\''")}'`,
|
|
69
|
+
timeout,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
success: result.exitCode === 0,
|
|
74
|
+
stdout: result.stdout,
|
|
75
|
+
stderr: result.stderr,
|
|
76
|
+
elapsedMs: Date.now() - startTime,
|
|
77
|
+
retries,
|
|
78
|
+
hungProcessesKilled,
|
|
79
|
+
error: result.exitCode === 0 ? undefined : `Exit code: ${result.exitCode}`,
|
|
80
|
+
};
|
|
81
|
+
} catch (error: any) {
|
|
82
|
+
const elapsed = Date.now() - startTime;
|
|
83
|
+
const isTimeout = elapsed >= timeout;
|
|
84
|
+
|
|
85
|
+
console.error(
|
|
86
|
+
`[ssh-recovery] Attempt ${retries + 1} failed: ${error.message} (${elapsed}ms)`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (isTimeout) {
|
|
90
|
+
hungProcessesKilled = true;
|
|
91
|
+
|
|
92
|
+
// Kill stuck processes on remote server
|
|
93
|
+
await killStuckProcesses(host, user);
|
|
94
|
+
|
|
95
|
+
// Also kill any local SSH processes stuck on this host
|
|
96
|
+
killLocalSSHProcesses(host);
|
|
97
|
+
|
|
98
|
+
retries++;
|
|
99
|
+
} else {
|
|
100
|
+
// Non-timeout error (permission denied, etc.)
|
|
101
|
+
return {
|
|
102
|
+
success: false,
|
|
103
|
+
stdout: "",
|
|
104
|
+
stderr: "",
|
|
105
|
+
elapsedMs: elapsed,
|
|
106
|
+
retries,
|
|
107
|
+
hungProcessesKilled: false,
|
|
108
|
+
error: error.message,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// All retries exhausted
|
|
115
|
+
return {
|
|
116
|
+
success: false,
|
|
117
|
+
stdout: "",
|
|
118
|
+
stderr: "",
|
|
119
|
+
elapsedMs: 0,
|
|
120
|
+
retries,
|
|
121
|
+
hungProcessesKilled,
|
|
122
|
+
error: `Command failed after ${maxRetries + 1} attempts`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Kill stuck processes on remote server
|
|
128
|
+
*/
|
|
129
|
+
async function killStuckProcesses(host: string, user: string): Promise<void> {
|
|
130
|
+
const killCommands = [
|
|
131
|
+
// Kill stuck SSH sessions
|
|
132
|
+
`pkill -f "ssh.*${host}" || true`,
|
|
133
|
+
// Kill stuck PM2 commands
|
|
134
|
+
`pkill -f "pm2.*amos-saas" || true`,
|
|
135
|
+
// Kill stuck sudo commands
|
|
136
|
+
`pkill -f "sudo.*pm2" || true`,
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
for (const cmd of killCommands) {
|
|
140
|
+
try {
|
|
141
|
+
execSync(
|
|
142
|
+
`ssh -o BatchMode=yes -o ConnectTimeout=10 ${user}@${host} '${cmd}'`,
|
|
143
|
+
{ timeout: 10_000, encoding: "utf-8" },
|
|
144
|
+
);
|
|
145
|
+
} catch {
|
|
146
|
+
// Ignore errors from kill commands
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Kill local SSH processes stuck on this host
|
|
153
|
+
*/
|
|
154
|
+
function killLocalSSHProcesses(host: string): void {
|
|
155
|
+
try {
|
|
156
|
+
// Kill any local SSH processes connecting to this host
|
|
157
|
+
execSync(`pkill -f "ssh.*${host}" || true`, { timeout: 5000 });
|
|
158
|
+
} catch {
|
|
159
|
+
// Ignore
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if server is responsive
|
|
165
|
+
*/
|
|
166
|
+
export async function pingServer(
|
|
167
|
+
host: string,
|
|
168
|
+
user: string = "frappe",
|
|
169
|
+
): Promise<boolean> {
|
|
170
|
+
try {
|
|
171
|
+
execSync(
|
|
172
|
+
`ssh -o BatchMode=yes -o ConnectTimeout=5 ${user}@${host} 'echo ok'`,
|
|
173
|
+
{ timeout: 10_000 },
|
|
174
|
+
);
|
|
175
|
+
return true;
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get PM2 status with hang protection
|
|
183
|
+
*/
|
|
184
|
+
export async function getPM2Status(
|
|
185
|
+
host: string,
|
|
186
|
+
pm2Path?: string,
|
|
187
|
+
): Promise<SSHResult> {
|
|
188
|
+
const pm2Cmd = pm2Path || "pm2";
|
|
189
|
+
const command = `PM2_HOME=/home/frappe/.pm2 ${pm2Cmd} status`;
|
|
190
|
+
|
|
191
|
+
return sshWithRecovery(
|
|
192
|
+
{
|
|
193
|
+
host,
|
|
194
|
+
timeout: 30_000, // Status should be fast
|
|
195
|
+
maxRetries: 1,
|
|
196
|
+
},
|
|
197
|
+
command,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Restart PM2 process with hang protection
|
|
203
|
+
*/
|
|
204
|
+
export async function pm2Restart(
|
|
205
|
+
host: string,
|
|
206
|
+
app: string,
|
|
207
|
+
): Promise<SSHResult> {
|
|
208
|
+
const command = `PM2_HOME=/home/frappe/.pm2 pm2 restart ${app}`;
|
|
209
|
+
|
|
210
|
+
return sshWithRecovery(
|
|
211
|
+
{
|
|
212
|
+
host,
|
|
213
|
+
timeout: 180_000, // 3 min for restart
|
|
214
|
+
maxRetries: 2,
|
|
215
|
+
},
|
|
216
|
+
command,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Execute arbitrary command with hang protection
|
|
222
|
+
*/
|
|
223
|
+
export async function safeSSHCommand(
|
|
224
|
+
host: string,
|
|
225
|
+
command: string,
|
|
226
|
+
options: { timeout?: number; maxRetries?: number } = {},
|
|
227
|
+
): Promise<SSHResult> {
|
|
228
|
+
return sshWithRecovery(
|
|
229
|
+
{
|
|
230
|
+
host,
|
|
231
|
+
timeout: options.timeout ?? 120_000,
|
|
232
|
+
maxRetries: options.maxRetries ?? 2,
|
|
233
|
+
},
|
|
234
|
+
command,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- Helper for promise-based exec with timeout ---
|
|
239
|
+
|
|
240
|
+
interface ExecResult {
|
|
241
|
+
exitCode: number;
|
|
242
|
+
stdout: string;
|
|
243
|
+
stderr: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function execAsync(command: string, timeoutMs: number): Promise<ExecResult> {
|
|
247
|
+
return new Promise((resolve, reject) => {
|
|
248
|
+
const proc = exec(command, { encoding: "utf-8" }, (error, stdout, stderr) => {
|
|
249
|
+
if (error) {
|
|
250
|
+
reject(error);
|
|
251
|
+
} else {
|
|
252
|
+
resolve({ exitCode: 0, stdout, stderr });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const timer = setAsyncTimeout(() => {
|
|
257
|
+
// Kill the process tree
|
|
258
|
+
try {
|
|
259
|
+
proc.kill("SIGKILL");
|
|
260
|
+
} catch {
|
|
261
|
+
// Process already dead
|
|
262
|
+
}
|
|
263
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms`));
|
|
264
|
+
}, timeoutMs);
|
|
265
|
+
|
|
266
|
+
proc.on("exit", (code) => {
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
resolve({
|
|
269
|
+
exitCode: code ?? 0,
|
|
270
|
+
stdout: (proc.stdout || "").toString(),
|
|
271
|
+
stderr: (proc.stderr || "").toString(),
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
proc.on("error", (err) => {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
reject(err);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-harness/capability-registry",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.26",
|
|
4
4
|
"description": "Capability Registry for pi-harness-runtime — tracks model capabilities and enables capability-based routing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|