pi-harness-runtime 1.1.333 → 1.1.334
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/README.md +2 -2
- package/RULES.md +45 -0
- package/harness/skill-sync.ts +11 -2
- package/package.json +3 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/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/codex-watcher/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/durable-review/package.json +1 -1
- package/packages/e2e-tester/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/jev-judge/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/paperclip-integration/dist/index.js +1 -1
- package/packages/paperclip-integration/dist/index.js.map +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/paperclip-integration/src/index.ts +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/qdrant-skills/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 +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/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/telegram/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/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/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
- package/scripts/r2-storage.ts +217 -0
- package/scripts/telegram-mobile-bot.ts +438 -0
- package/scripts/telegram-mobile-integration.ts +480 -0
- package/scripts/test-r2.ts +63 -0
- package/scripts/test-telegram-approval.ts +4 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R2 Storage - Cloudflare R2 integration for Telegram log storage
|
|
3
|
+
*
|
|
4
|
+
* Uses @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner
|
|
5
|
+
* Credentials loaded from key files per sensitive data rules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
11
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Configuration - Load from key files
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
export interface R2Config {
|
|
18
|
+
accountId: string;
|
|
19
|
+
accessKeyId: string;
|
|
20
|
+
secretAccessKey: string;
|
|
21
|
+
bucket: string;
|
|
22
|
+
endpoint?: string;
|
|
23
|
+
publicUrl?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load R2 config from key files
|
|
28
|
+
*/
|
|
29
|
+
export function loadR2Config(): R2Config | null {
|
|
30
|
+
const keysDir = join(process.env.HOME || "/home/frappe", ".pi-harness-runtime", "keys");
|
|
31
|
+
|
|
32
|
+
const accountIdFile = join(keysDir, "r2-account-id.txt");
|
|
33
|
+
const accessKeyFile = join(keysDir, "r2-access-key-id.txt");
|
|
34
|
+
const secretKeyFile = join(keysDir, "r2-secret-access-key.txt");
|
|
35
|
+
const bucketFile = join(keysDir, "r2-bucket-name.txt");
|
|
36
|
+
const endpointFile = join(keysDir, "r2-endpoint.txt");
|
|
37
|
+
const publicUrlFile = join(keysDir, "r2-public-url.txt");
|
|
38
|
+
|
|
39
|
+
// Check if R2 is configured
|
|
40
|
+
if (!existsSync(accountIdFile) || !existsSync(accessKeyFile) || !existsSync(secretKeyFile)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
accountId: readFileSync(accountIdFile, "utf8").trim(),
|
|
46
|
+
accessKeyId: readFileSync(accessKeyFile, "utf8").trim(),
|
|
47
|
+
secretAccessKey: readFileSync(secretKeyFile, "utf8").trim(),
|
|
48
|
+
bucket: existsSync(bucketFile) ? readFileSync(bucketFile, "utf8").trim() : "telegram-logs",
|
|
49
|
+
endpoint: existsSync(endpointFile) ? readFileSync(endpointFile, "utf8").trim() : undefined,
|
|
50
|
+
publicUrl: existsSync(publicUrlFile) ? readFileSync(publicUrlFile, "utf8").trim() : undefined,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// R2 Client
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
let r2Client: R2Client | null = null;
|
|
59
|
+
|
|
60
|
+
export class R2Client {
|
|
61
|
+
private client: S3Client;
|
|
62
|
+
private bucket: string;
|
|
63
|
+
private publicUrl: string | null;
|
|
64
|
+
|
|
65
|
+
constructor(config: R2Config) {
|
|
66
|
+
// Build endpoint from accountId or use explicit endpoint
|
|
67
|
+
const endpoint = config.endpoint || `https://${config.accountId}.r2.cloudflarestorage.com`;
|
|
68
|
+
|
|
69
|
+
this.client = new S3Client({
|
|
70
|
+
region: "auto",
|
|
71
|
+
endpoint,
|
|
72
|
+
credentials: {
|
|
73
|
+
accessKeyId: config.accessKeyId,
|
|
74
|
+
secretAccessKey: config.secretAccessKey,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
this.bucket = config.bucket;
|
|
79
|
+
this.publicUrl = config.publicUrl || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Upload content to R2
|
|
84
|
+
*/
|
|
85
|
+
async upload(options: {
|
|
86
|
+
key: string;
|
|
87
|
+
body: string;
|
|
88
|
+
contentType?: string;
|
|
89
|
+
expiresIn?: number; // For signed URL
|
|
90
|
+
}): Promise<{ key: string; signedUrl: string; expiresIn: number }> {
|
|
91
|
+
const { key, body, contentType = "text/plain", expiresIn = 86400 } = options;
|
|
92
|
+
|
|
93
|
+
await this.client.send(
|
|
94
|
+
new PutObjectCommand({
|
|
95
|
+
Bucket: this.bucket,
|
|
96
|
+
Key: key,
|
|
97
|
+
Body: body,
|
|
98
|
+
ContentType: contentType,
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Generate signed URL for access
|
|
103
|
+
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
|
|
104
|
+
const signedUrl = await getSignedUrl(this.client, new GetObjectCommand({
|
|
105
|
+
Bucket: this.bucket,
|
|
106
|
+
Key: key,
|
|
107
|
+
}), { expiresIn });
|
|
108
|
+
|
|
109
|
+
console.log(`[R2] Uploaded: ${key}`);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
key,
|
|
113
|
+
signedUrl,
|
|
114
|
+
expiresIn,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get public URL for a key (if publicUrl is configured)
|
|
120
|
+
*/
|
|
121
|
+
getPublicUrl(key: string): string | null {
|
|
122
|
+
if (this.publicUrl) {
|
|
123
|
+
return `${this.publicUrl}/${key}`;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get or create R2 client singleton
|
|
131
|
+
*/
|
|
132
|
+
export function getR2Client(): R2Client | null {
|
|
133
|
+
if (r2Client) return r2Client;
|
|
134
|
+
|
|
135
|
+
const config = loadR2Config();
|
|
136
|
+
if (!config) return null;
|
|
137
|
+
|
|
138
|
+
r2Client = new R2Client(config);
|
|
139
|
+
return r2Client;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if R2 is configured
|
|
144
|
+
*/
|
|
145
|
+
export function isR2Configured(): boolean {
|
|
146
|
+
return loadR2Config() !== null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ============================================================================
|
|
150
|
+
// High-level API
|
|
151
|
+
// ============================================================================
|
|
152
|
+
|
|
153
|
+
interface UploadResult {
|
|
154
|
+
key: string;
|
|
155
|
+
url: string;
|
|
156
|
+
expiresIn: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Upload logs to R2 and return a signed URL
|
|
161
|
+
*
|
|
162
|
+
* @param content - Log content to upload
|
|
163
|
+
* @param prefix - Path prefix (default: "logs")
|
|
164
|
+
* @param expiresIn - URL expiration in seconds (default: 86400 = 24h)
|
|
165
|
+
*/
|
|
166
|
+
export async function uploadLogsToR2(
|
|
167
|
+
content: string,
|
|
168
|
+
prefix: string = "logs",
|
|
169
|
+
expiresIn: number = 86400
|
|
170
|
+
): Promise<UploadResult | null> {
|
|
171
|
+
const client = getR2Client();
|
|
172
|
+
if (!client) {
|
|
173
|
+
console.log("[R2] Not configured - skipping upload");
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const key = `${prefix}/${Date.now()}-${generateId()}.txt`;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = await client.upload({
|
|
181
|
+
key,
|
|
182
|
+
body: content,
|
|
183
|
+
contentType: "text/plain",
|
|
184
|
+
expiresIn,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
key: result.key,
|
|
189
|
+
url: result.signedUrl,
|
|
190
|
+
expiresIn: result.expiresIn,
|
|
191
|
+
};
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error("[R2] Upload failed:", error);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Generate a short random ID
|
|
200
|
+
*/
|
|
201
|
+
function generateId(): string {
|
|
202
|
+
return Math.random().toString(36).slice(2, 10);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// Key Files Required
|
|
207
|
+
// ============================================================================
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Required key files for R2:
|
|
211
|
+
* - ~/.pi-harness-runtime/keys/r2-account-id.txt
|
|
212
|
+
* - ~/.pi-harness-runtime/keys/r2-access-key-id.txt
|
|
213
|
+
* - ~/.pi-harness-runtime/keys/r2-secret-access-key.txt
|
|
214
|
+
* - ~/.pi-harness-runtime/keys/r2-bucket-name.txt (optional, default: "telegram-logs")
|
|
215
|
+
* - ~/.pi-harness-runtime/keys/r2-endpoint.txt (optional)
|
|
216
|
+
* - ~/.pi-harness-runtime/keys/r2-public-url.txt (optional)
|
|
217
|
+
*/
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Mobile Bot - Mobile-First Control Surface
|
|
3
|
+
*
|
|
4
|
+
* Design principles:
|
|
5
|
+
* - ZERO typing - all actions via buttons
|
|
6
|
+
* - One-tap navigation - contextual action buttons
|
|
7
|
+
* - Persistent context - job state always visible
|
|
8
|
+
* - Quick status - current job at a glance
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
const keysDir = path.join(process.env.HOME || "/home/frappe", ".pi-harness-runtime", "keys");
|
|
16
|
+
const dataDir = path.join(process.env.HOME || "/home/frappe", ".pi-harness-runtime", "data");
|
|
17
|
+
|
|
18
|
+
// Load config
|
|
19
|
+
const botToken = fs.readFileSync(path.join(keysDir, "telegram-bot-token.txt"), "utf8").trim();
|
|
20
|
+
const chatId = fs.readFileSync(path.join(keysDir, "telegram-chat-id.txt"), "utf8").trim();
|
|
21
|
+
|
|
22
|
+
// API helpers
|
|
23
|
+
async function sendMessage(text: string, keyboard?: InlineKeyboard): Promise<number | null> {
|
|
24
|
+
const response = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
chat_id: chatId,
|
|
29
|
+
text,
|
|
30
|
+
parse_mode: "HTML",
|
|
31
|
+
reply_markup: keyboard ? { inline_keyboard: keyboard } : undefined,
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
const data = await response.json() as { ok: boolean; result?: { message_id: number } };
|
|
35
|
+
return data.ok ? data.result?.message_id ?? null : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function editMessage(messageId: number, text: string, keyboard?: InlineKeyboard): Promise<boolean> {
|
|
39
|
+
const response = await fetch(`https://api.telegram.org/bot${botToken}/editMessageText`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
chat_id: chatId,
|
|
44
|
+
message_id: messageId,
|
|
45
|
+
text,
|
|
46
|
+
parse_mode: "HTML",
|
|
47
|
+
reply_markup: keyboard ? { inline_keyboard: keyboard } : undefined,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
const data = await response.json() as { ok: boolean };
|
|
51
|
+
return data.ok;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function answerCallback(queryId: string, text?: string): Promise<void> {
|
|
55
|
+
await fetch(`https://api.telegram.org/bot${botToken}/answerCallbackQuery`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
callback_query_id: queryId,
|
|
60
|
+
text,
|
|
61
|
+
show_alert: false,
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Types
|
|
67
|
+
type InlineKeyboard = Array<Array<{ text: string; callback_data: string }>>;
|
|
68
|
+
|
|
69
|
+
interface JobStatus {
|
|
70
|
+
id: string;
|
|
71
|
+
title: string;
|
|
72
|
+
status: "pending" | "running" | "done" | "failed";
|
|
73
|
+
progress?: string;
|
|
74
|
+
updatedAt: string;
|
|
75
|
+
messageId?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface JobDetail {
|
|
79
|
+
id: string;
|
|
80
|
+
title: string;
|
|
81
|
+
description?: string;
|
|
82
|
+
status: string;
|
|
83
|
+
logs?: string[];
|
|
84
|
+
diff?: string;
|
|
85
|
+
error?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// State management
|
|
89
|
+
class JobState {
|
|
90
|
+
private stateFile: string;
|
|
91
|
+
private jobs: Map<string, JobStatus> = new Map();
|
|
92
|
+
private currentJobId: string | null = null;
|
|
93
|
+
|
|
94
|
+
constructor() {
|
|
95
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
96
|
+
this.stateFile = path.join(dataDir, "telegram-jobs.json");
|
|
97
|
+
this.load();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private load() {
|
|
101
|
+
try {
|
|
102
|
+
if (existsSync(this.stateFile)) {
|
|
103
|
+
const data = JSON.parse(readFileSync(this.stateFile, "utf8"));
|
|
104
|
+
this.jobs = new Map(data.jobs || []);
|
|
105
|
+
this.currentJobId = data.currentJobId || null;
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
console.error("Failed to load state:", e);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private save() {
|
|
113
|
+
writeFileSync(this.stateFile, JSON.stringify({
|
|
114
|
+
jobs: Array.from(this.jobs.entries()),
|
|
115
|
+
currentJobId: this.currentJobId,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
setCurrentJob(jobId: string | null) {
|
|
120
|
+
this.currentJobId = jobId;
|
|
121
|
+
this.save();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getCurrentJob(): JobStatus | null {
|
|
125
|
+
return this.currentJobId ? this.jobs.get(this.currentJobId) ?? null : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
updateJob(job: JobStatus) {
|
|
129
|
+
this.jobs.set(job.id, job);
|
|
130
|
+
this.save();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
getAllJobs(): JobStatus[] {
|
|
134
|
+
return Array.from(this.jobs.values()).sort(
|
|
135
|
+
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
removeJob(jobId: string) {
|
|
140
|
+
this.jobs.delete(jobId);
|
|
141
|
+
if (this.currentJobId === jobId) {
|
|
142
|
+
this.currentJobId = this.jobs.keys().next().value ?? null;
|
|
143
|
+
}
|
|
144
|
+
this.save();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Button generators - NO typing required!
|
|
149
|
+
class ButtonKit {
|
|
150
|
+
static jobCard(job: JobStatus): InlineKeyboard {
|
|
151
|
+
const statusIcon = job.status === "running" ? "🔄" : job.status === "done" ? "✅" : job.status === "failed" ? "❌" : "⏳";
|
|
152
|
+
return [
|
|
153
|
+
[
|
|
154
|
+
{ text: "📋 Details", callback_data: `job:details:${job.id}` },
|
|
155
|
+
{ text: "📜 Logs", callback_data: `job:logs:${job.id}` },
|
|
156
|
+
],
|
|
157
|
+
[
|
|
158
|
+
{ text: "🔄 Retry", callback_data: `job:retry:${job.id}` },
|
|
159
|
+
{ text: "❌ Cancel", callback_data: `job:cancel:${job.id}` },
|
|
160
|
+
],
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
static mainMenu(): InlineKeyboard {
|
|
165
|
+
return [
|
|
166
|
+
[
|
|
167
|
+
{ text: "📋 My Jobs", callback_data: "nav:jobs" },
|
|
168
|
+
{ text: "🔍 Search", callback_data: "nav:search" },
|
|
169
|
+
],
|
|
170
|
+
[
|
|
171
|
+
{ text: "📊 Status", callback_data: "nav:status" },
|
|
172
|
+
{ text: "⚙️ Settings", callback_data: "nav:settings" },
|
|
173
|
+
],
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
static jobList(jobs: JobStatus[]): InlineKeyboard {
|
|
178
|
+
const buttons: InlineKeyboard = [];
|
|
179
|
+
for (const job of jobs.slice(0, 5)) {
|
|
180
|
+
const icon = job.status === "running" ? "🔄" : job.status === "done" ? "✅" : job.status === "failed" ? "❌" : "⏳";
|
|
181
|
+
buttons.push([{ text: `${icon} ${job.title.slice(0, 30)}`, callback_data: `job:select:${job.id}` }]);
|
|
182
|
+
}
|
|
183
|
+
buttons.push([{ text: "🔙 Back to Menu", callback_data: "nav:menu" }]);
|
|
184
|
+
return buttons;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
static detailsPage(job: JobDetail): InlineKeyboard {
|
|
188
|
+
return [
|
|
189
|
+
[
|
|
190
|
+
{ text: "📜 Full Logs", callback_data: `job:logs:${job.id}` },
|
|
191
|
+
{ text: "📝 View Diff", callback_data: `job:diff:${job.id}` },
|
|
192
|
+
],
|
|
193
|
+
[
|
|
194
|
+
{ text: "🔄 Retry", callback_data: `job:retry:${job.id}` },
|
|
195
|
+
{ text: "🗑️ Dismiss", callback_data: `job:dismiss:${job.id}` },
|
|
196
|
+
],
|
|
197
|
+
[
|
|
198
|
+
{ text: "🔙 Back", callback_data: "nav:jobs" },
|
|
199
|
+
],
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
static confirmAction(action: string, targetId: string): InlineKeyboard {
|
|
204
|
+
return [
|
|
205
|
+
[
|
|
206
|
+
{ text: "✅ Confirm", callback_data: `action:confirm:${action}:${targetId}` },
|
|
207
|
+
{ text: "❌ Cancel", callback_data: `action:cancel:${action}:${targetId}` },
|
|
208
|
+
],
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Renderers
|
|
214
|
+
function formatJobCard(job: JobStatus): string {
|
|
215
|
+
const statusIcon = job.status === "running" ? "🔄" : job.status === "done" ? "✅" : job.status === "failed" ? "❌" : "⏳";
|
|
216
|
+
const time = new Date(job.updatedAt).toLocaleTimeString();
|
|
217
|
+
|
|
218
|
+
return `<b>${statusIcon} ${job.title}</b>\n` +
|
|
219
|
+
`Status: ${job.status}${job.progress ? ` (${job.progress})` : ""}\n` +
|
|
220
|
+
`Updated: ${time}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function formatJobDetail(job: JobDetail): string {
|
|
224
|
+
const statusIcon = job.status === "running" ? "🔄" : job.status === "done" ? "✅" : job.status === "failed" ? "❌" : "⏳";
|
|
225
|
+
|
|
226
|
+
let text = `<b>${statusIcon} ${job.title}</b>\n`;
|
|
227
|
+
text += `─────────────────\n`;
|
|
228
|
+
text += `Status: <code>${job.status}</code>\n`;
|
|
229
|
+
|
|
230
|
+
if (job.description) {
|
|
231
|
+
text += `\n${job.description}\n`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (job.error) {
|
|
235
|
+
text += `\n⚠️ <b>Error:</b>\n<code>${job.error.slice(0, 200)}</code>`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (job.logs && job.logs.length > 0) {
|
|
239
|
+
text += `\n\n📜 <b>Recent Logs:</b>`;
|
|
240
|
+
for (const log of job.logs.slice(0, 3)) {
|
|
241
|
+
text += `\n<code>${log.slice(0, 100)}</code>`;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return text;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Actions
|
|
249
|
+
async function handleJobDetails(jobId: string): Promise<string> {
|
|
250
|
+
// In real implementation, fetch from job state or API
|
|
251
|
+
return formatJobDetail({
|
|
252
|
+
id: jobId,
|
|
253
|
+
title: "Sample Job",
|
|
254
|
+
status: "running",
|
|
255
|
+
description: "This job is processing...",
|
|
256
|
+
logs: ["Starting process...", "Running tests...", "All tests passed"],
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function handleJobLogs(jobId: string, page = 0): Promise<{ text: string; keyboard: InlineKeyboard }> {
|
|
261
|
+
// In real implementation, fetch from log storage
|
|
262
|
+
const logs = [
|
|
263
|
+
`[10:00] Job started`,
|
|
264
|
+
`[10:01] Running tests...`,
|
|
265
|
+
`[10:02] Test 1: PASS`,
|
|
266
|
+
`[10:02] Test 2: PASS`,
|
|
267
|
+
`[10:03] All 24 tests passed!`,
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
const startIdx = page * 5;
|
|
271
|
+
const pageLogs = logs.slice(startIdx, startIdx + 5);
|
|
272
|
+
|
|
273
|
+
const text = `<b>📜 Job Logs</b>\n<code>${jobId}</code>\n─────────────────\n` +
|
|
274
|
+
pageLogs.map(l => `📍 ${l}`).join("\n") +
|
|
275
|
+
`\n─────────────────\nPage ${page + 1}`;
|
|
276
|
+
|
|
277
|
+
const keyboard: InlineKeyboard = [];
|
|
278
|
+
if (logs.length > startIdx + 5) {
|
|
279
|
+
keyboard.push([{ text: "▶️ Next", callback_data: `job:logs:${jobId}:${page + 1}` }]);
|
|
280
|
+
}
|
|
281
|
+
if (page > 0) {
|
|
282
|
+
keyboard.push([{ text: "◀️ Prev", callback_data: `job:logs:${jobId}:${page - 1}` }]);
|
|
283
|
+
}
|
|
284
|
+
keyboard.push([{ text: "🔙 Back", callback_data: `job:details:${jobId}` }]);
|
|
285
|
+
|
|
286
|
+
return { text, keyboard };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Command handlers - triggered by buttons
|
|
290
|
+
export async function handleCallback(queryId: string, data: string): Promise<void> {
|
|
291
|
+
const parts = data.split(":");
|
|
292
|
+
const action = parts[0];
|
|
293
|
+
const subAction = parts[1];
|
|
294
|
+
const targetId = parts[2];
|
|
295
|
+
|
|
296
|
+
switch (action) {
|
|
297
|
+
case "job": {
|
|
298
|
+
switch (subAction) {
|
|
299
|
+
case "select": {
|
|
300
|
+
// Show job details with action buttons
|
|
301
|
+
const job = new JobState().getAllJobs().find(j => j.id === targetId);
|
|
302
|
+
if (job) {
|
|
303
|
+
const details = await handleJobDetails(targetId);
|
|
304
|
+
await sendMessage(details, ButtonKit.detailsPage({ ...job, description: "" }));
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
case "details": {
|
|
310
|
+
const details = await handleJobDetails(targetId);
|
|
311
|
+
await sendMessage(details, ButtonKit.detailsPage({ id: targetId, title: "", status: "" }));
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
case "logs": {
|
|
316
|
+
const page = parseInt(parts[3] || "0");
|
|
317
|
+
const { text, keyboard } = await handleJobLogs(targetId, page);
|
|
318
|
+
await sendMessage(text, keyboard);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case "retry": {
|
|
323
|
+
await sendMessage(
|
|
324
|
+
`🔄 Retry <code>${targetId}</code>?`,
|
|
325
|
+
ButtonKit.confirmAction("retry", targetId)
|
|
326
|
+
);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
case "cancel": {
|
|
331
|
+
await sendMessage(
|
|
332
|
+
`❌ Cancel <code>${targetId}</code>?`,
|
|
333
|
+
ButtonKit.confirmAction("cancel", targetId)
|
|
334
|
+
);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
case "dismiss": {
|
|
339
|
+
new JobState().removeJob(targetId);
|
|
340
|
+
await sendMessage("🗑️ Job dismissed", ButtonKit.mainMenu());
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
case "action": {
|
|
348
|
+
if (subAction === "confirm") {
|
|
349
|
+
const actionToConfirm = parts[2];
|
|
350
|
+
const id = parts[3];
|
|
351
|
+
|
|
352
|
+
// Execute the confirmed action
|
|
353
|
+
const jobPipe = path.join(dataDir, "job-commands.jsonl");
|
|
354
|
+
appendFileSync(jobPipe, JSON.stringify({
|
|
355
|
+
command: actionToConfirm,
|
|
356
|
+
jobId: id,
|
|
357
|
+
timestamp: new Date().toISOString(),
|
|
358
|
+
}) + "\n");
|
|
359
|
+
|
|
360
|
+
const messages: Record<string, string> = {
|
|
361
|
+
retry: `🔄 Retrying job <code>${id}</code>...`,
|
|
362
|
+
cancel: `❌ Cancelled job <code>${id}</code>`,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
await sendMessage(messages[actionToConfirm] || "Action completed", ButtonKit.mainMenu());
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
case "nav": {
|
|
371
|
+
switch (subAction) {
|
|
372
|
+
case "menu": {
|
|
373
|
+
await sendMessage(
|
|
374
|
+
"🏠 <b>pi-harness Control Panel</b>\n\nSelect an action:",
|
|
375
|
+
ButtonKit.mainMenu()
|
|
376
|
+
);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
case "jobs": {
|
|
381
|
+
const jobs = new JobState().getAllJobs();
|
|
382
|
+
if (jobs.length === 0) {
|
|
383
|
+
await sendMessage("📋 <b>No Active Jobs</b>\n\nNo jobs in progress.", ButtonKit.mainMenu());
|
|
384
|
+
} else {
|
|
385
|
+
await sendMessage(
|
|
386
|
+
"📋 <b>Your Jobs</b>\n\nSelect a job:",
|
|
387
|
+
ButtonKit.jobList(jobs)
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
case "status": {
|
|
394
|
+
const current = new JobState().getCurrentJob();
|
|
395
|
+
const text = current
|
|
396
|
+
? formatJobCard(current) + "\n\n<i>Last updated: just now</i>"
|
|
397
|
+
: "✅ <b>All Clear</b>\n\nNo active jobs.";
|
|
398
|
+
await sendMessage(text, ButtonKit.mainMenu());
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
case "search": {
|
|
403
|
+
await sendMessage(
|
|
404
|
+
"🔍 <b>Search</b>\n\nClick a job to search its logs:",
|
|
405
|
+
ButtonKit.jobList(new JobState().getAllJobs())
|
|
406
|
+
);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
case "settings": {
|
|
411
|
+
await sendMessage(
|
|
412
|
+
"⚙️ <b>Settings</b>\n\n" +
|
|
413
|
+
"• Notifications: ON\n" +
|
|
414
|
+
"• Auto-retry: OFF\n" +
|
|
415
|
+
"• Log verbosity: Normal",
|
|
416
|
+
ButtonKit.mainMenu()
|
|
417
|
+
);
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
await answerCallback(queryId);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Send initial welcome
|
|
429
|
+
export async function sendWelcome(): Promise<void> {
|
|
430
|
+
await sendMessage(
|
|
431
|
+
"🏠 <b>pi-harness Control Panel</b>\n\n" +
|
|
432
|
+
"Manage your coding tasks from anywhere.\n" +
|
|
433
|
+
"Tap a button below to get started:",
|
|
434
|
+
ButtonKit.mainMenu()
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|