@hienlh/ppm 0.8.70 → 0.8.72
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/CHANGELOG.md +26 -0
- package/dist/web/assets/{browser-tab-sn8vZniz.js → browser-tab-D5GfU4Ja.js} +1 -1
- package/dist/web/assets/chat-tab-BJeNwwUM.js +8 -0
- package/dist/web/assets/code-editor-CTjgdXh2.js +2 -0
- package/dist/web/assets/{database-viewer-BOnawWoi.js → database-viewer-QzEuetE6.js} +1 -1
- package/dist/web/assets/{diff-viewer-CYSw0YBG.js → diff-viewer-CvZ06EAH.js} +1 -1
- package/dist/web/assets/{git-graph-BNTU6kmo.js → git-graph-BQqdvSjX.js} +1 -1
- package/dist/web/assets/index-5a-tMkk5.js +37 -0
- package/dist/web/assets/{index-DJ1Bqwo4.css → index-CzwYVupc.css} +1 -1
- package/dist/web/assets/keybindings-store-zY8zbJ2c.js +1 -0
- package/dist/web/assets/{markdown-renderer-CW2c3h_9.js → markdown-renderer-BVxlq4zO.js} +1 -1
- package/dist/web/assets/{postgres-viewer-D95__akI.js → postgres-viewer-DP0FOQOa.js} +1 -1
- package/dist/web/assets/{settings-tab-CwLkeZaa.js → settings-tab-CcmhnYpw.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-J18kIhk2.js → sqlite-viewer-4a4hHLZk.js} +1 -1
- package/dist/web/assets/{terminal-tab-BKETi9uD.js → terminal-tab-CKsBIgnq.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-Dsn8sLad.js → use-monaco-theme-BwIb9BHq.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/cli/commands/restart.ts +9 -1
- package/src/cli/commands/status.ts +19 -0
- package/src/index.ts +1 -2
- package/src/server/index.ts +17 -154
- package/src/server/routes/chat.ts +33 -3
- package/src/services/cloud-ws.service.ts +208 -0
- package/src/services/db.service.ts +31 -1
- package/src/services/supervisor.ts +193 -12
- package/src/types/chat.ts +1 -0
- package/src/web/components/chat/chat-history-bar.tsx +35 -3
- package/src/web/components/chat/session-picker.tsx +78 -31
- package/src/web/components/layout/editor-panel.tsx +71 -19
- package/dist/web/assets/chat-tab-CVN2falD.js +0 -8
- package/dist/web/assets/code-editor-BNAZzdyF.js +0 -2
- package/dist/web/assets/index-ButO-DnP.js +0 -37
- package/dist/web/assets/keybindings-store-BxDBTcFM.js +0 -1
|
@@ -8,8 +8,7 @@ import { renameSession as sdkRenameSession } from "@anthropic-ai/claude-agent-sd
|
|
|
8
8
|
import { listSlashItems } from "../../services/slash-items.service.ts";
|
|
9
9
|
import { getCachedUsage, refreshUsageNow } from "../../services/claude-usage.service.ts";
|
|
10
10
|
import { getSessionLog } from "../../services/session-log.service.ts";
|
|
11
|
-
import { getSessionMapping } from "../../services/db.service.ts";
|
|
12
|
-
import { getSessionMapping, setSessionTitle } from "../../services/db.service.ts";
|
|
11
|
+
import { getSessionMapping, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession } from "../../services/db.service.ts";
|
|
13
12
|
import { ok, err } from "../../types/api.ts";
|
|
14
13
|
|
|
15
14
|
type Env = { Variables: { projectPath: string; projectName: string } };
|
|
@@ -64,7 +63,16 @@ chatRoutes.get("/sessions", async (c) => {
|
|
|
64
63
|
const projectPath = c.get("projectPath");
|
|
65
64
|
const providerId = c.req.query("providerId");
|
|
66
65
|
const sessions = await chatService.listSessions(providerId, projectPath);
|
|
67
|
-
|
|
66
|
+
// Enrich with pin status
|
|
67
|
+
const pinnedIds = getPinnedSessionIds();
|
|
68
|
+
const enriched = sessions.map((s) => ({ ...s, pinned: pinnedIds.has(s.id) }));
|
|
69
|
+
// Sort: pinned first (by pinned_at implicit via Set order), then unpinned by createdAt
|
|
70
|
+
enriched.sort((a, b) => {
|
|
71
|
+
if (a.pinned && !b.pinned) return -1;
|
|
72
|
+
if (!a.pinned && b.pinned) return 1;
|
|
73
|
+
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
74
|
+
});
|
|
75
|
+
return c.json(ok(enriched));
|
|
68
76
|
} catch (e) {
|
|
69
77
|
return c.json(err((e as Error).message), 500);
|
|
70
78
|
}
|
|
@@ -134,6 +142,28 @@ chatRoutes.patch("/sessions/:id", async (c) => {
|
|
|
134
142
|
}
|
|
135
143
|
});
|
|
136
144
|
|
|
145
|
+
/** PUT /chat/sessions/:id/pin — pin a session */
|
|
146
|
+
chatRoutes.put("/sessions/:id/pin", (c) => {
|
|
147
|
+
try {
|
|
148
|
+
const id = c.req.param("id");
|
|
149
|
+
pinSession(id);
|
|
150
|
+
return c.json(ok({ id, pinned: true }));
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return c.json(err((e as Error).message), 500);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/** DELETE /chat/sessions/:id/pin — unpin a session */
|
|
157
|
+
chatRoutes.delete("/sessions/:id/pin", (c) => {
|
|
158
|
+
try {
|
|
159
|
+
const id = c.req.param("id");
|
|
160
|
+
unpinSession(id);
|
|
161
|
+
return c.json(ok({ id, pinned: false }));
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return c.json(err((e as Error).message), 500);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
137
167
|
/** POST /chat/sessions/:id/fork — fork session into a new one (for rewind/branch) */
|
|
138
168
|
chatRoutes.post("/sessions/:id/fork", async (c) => {
|
|
139
169
|
try {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud WebSocket client — persistent connection from supervisor to PPM Cloud.
|
|
3
|
+
* Auto-reconnects with exponential backoff + jitter. Queues messages when disconnected.
|
|
4
|
+
*/
|
|
5
|
+
import { appendFileSync } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
|
|
9
|
+
// ─── Types (must match Cloud's ws-types.ts) ─────────
|
|
10
|
+
interface WsMessage {
|
|
11
|
+
type: string;
|
|
12
|
+
id?: string;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface HeartbeatMsg extends WsMessage {
|
|
17
|
+
type: "heartbeat";
|
|
18
|
+
tunnelUrl: string | null;
|
|
19
|
+
state: string;
|
|
20
|
+
appVersion: string;
|
|
21
|
+
serverPid: number | null;
|
|
22
|
+
uptime: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface StateChangeMsg extends WsMessage {
|
|
26
|
+
type: "state_change";
|
|
27
|
+
from: string;
|
|
28
|
+
to: string;
|
|
29
|
+
reason: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CommandResultMsg extends WsMessage {
|
|
33
|
+
type: "command_result";
|
|
34
|
+
id: string;
|
|
35
|
+
success: boolean;
|
|
36
|
+
error?: string;
|
|
37
|
+
data?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type OutboundMsg = HeartbeatMsg | StateChangeMsg | CommandResultMsg;
|
|
41
|
+
|
|
42
|
+
interface CommandMsg extends WsMessage {
|
|
43
|
+
type: "command";
|
|
44
|
+
id: string;
|
|
45
|
+
action: string;
|
|
46
|
+
params?: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type CommandHandler = (cmd: CommandMsg) => void;
|
|
50
|
+
|
|
51
|
+
// ─── Constants ──────────────────────────────────────
|
|
52
|
+
const BACKOFF_STEPS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
|
|
53
|
+
const MAX_QUEUE_SIZE = 50;
|
|
54
|
+
const HEARTBEAT_INTERVAL_MS = 60_000; // 60s via WS
|
|
55
|
+
|
|
56
|
+
// ─── State ──────────────────────────────────────────
|
|
57
|
+
let ws: WebSocket | null = null;
|
|
58
|
+
let connected = false;
|
|
59
|
+
let reconnecting = false;
|
|
60
|
+
let reconnectAttempt = 0;
|
|
61
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
62
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
63
|
+
let commandHandler: CommandHandler | null = null;
|
|
64
|
+
let outboundQueue: OutboundMsg[] = [];
|
|
65
|
+
let wsUrl = "";
|
|
66
|
+
let shouldConnect = false;
|
|
67
|
+
|
|
68
|
+
// Credentials for first-message auth
|
|
69
|
+
let deviceId = "";
|
|
70
|
+
let secretKey = "";
|
|
71
|
+
|
|
72
|
+
// For heartbeat payload
|
|
73
|
+
let getHeartbeatData: (() => HeartbeatMsg) | null = null;
|
|
74
|
+
|
|
75
|
+
// ─── Public API ─────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export function connect(opts: {
|
|
78
|
+
cloudUrl: string;
|
|
79
|
+
deviceId: string;
|
|
80
|
+
secretKey: string;
|
|
81
|
+
heartbeatFn: () => HeartbeatMsg;
|
|
82
|
+
}): void {
|
|
83
|
+
// No secret_key in URL — auth via first message after connect
|
|
84
|
+
wsUrl = `${opts.cloudUrl.replace(/^http/, "ws")}/ws/device`;
|
|
85
|
+
deviceId = opts.deviceId;
|
|
86
|
+
secretKey = opts.secretKey;
|
|
87
|
+
getHeartbeatData = opts.heartbeatFn;
|
|
88
|
+
shouldConnect = true;
|
|
89
|
+
reconnectAttempt = 0;
|
|
90
|
+
doConnect();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function disconnect(): void {
|
|
94
|
+
shouldConnect = false;
|
|
95
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
96
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
97
|
+
if (ws) {
|
|
98
|
+
try { ws.close(1000, "shutdown"); } catch {}
|
|
99
|
+
ws = null;
|
|
100
|
+
}
|
|
101
|
+
connected = false;
|
|
102
|
+
outboundQueue = [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function send(msg: OutboundMsg): void {
|
|
106
|
+
if (connected && ws?.readyState === WebSocket.OPEN) {
|
|
107
|
+
ws.send(JSON.stringify(msg));
|
|
108
|
+
} else {
|
|
109
|
+
outboundQueue.push(msg);
|
|
110
|
+
if (outboundQueue.length > MAX_QUEUE_SIZE) outboundQueue.shift();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function onCommand(handler: CommandHandler): void {
|
|
115
|
+
commandHandler = handler;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isConnected(): boolean {
|
|
119
|
+
return connected;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Internal ───────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function doConnect(): void {
|
|
125
|
+
if (!shouldConnect || reconnecting) return;
|
|
126
|
+
reconnecting = true;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
ws = new WebSocket(wsUrl);
|
|
130
|
+
} catch {
|
|
131
|
+
reconnecting = false;
|
|
132
|
+
scheduleReconnect();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
ws.onopen = () => {
|
|
137
|
+
reconnecting = false;
|
|
138
|
+
log("INFO", "Cloud WS connected, sending auth");
|
|
139
|
+
|
|
140
|
+
// Send auth as first message (not in URL)
|
|
141
|
+
ws!.send(JSON.stringify({
|
|
142
|
+
type: "auth",
|
|
143
|
+
deviceId,
|
|
144
|
+
secretKey,
|
|
145
|
+
timestamp: new Date().toISOString(),
|
|
146
|
+
version: 1,
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
connected = true;
|
|
150
|
+
reconnectAttempt = 0;
|
|
151
|
+
|
|
152
|
+
// Flush queued messages
|
|
153
|
+
while (outboundQueue.length > 0 && connected) {
|
|
154
|
+
const msg = outboundQueue.shift()!;
|
|
155
|
+
ws!.send(JSON.stringify(msg));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Send immediate heartbeat
|
|
159
|
+
if (getHeartbeatData) send(getHeartbeatData());
|
|
160
|
+
|
|
161
|
+
// Start periodic heartbeat
|
|
162
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
163
|
+
heartbeatTimer = setInterval(() => {
|
|
164
|
+
if (getHeartbeatData && connected) send(getHeartbeatData());
|
|
165
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
ws.onmessage = (event) => {
|
|
169
|
+
try {
|
|
170
|
+
const msg = JSON.parse(String(event.data)) as CommandMsg;
|
|
171
|
+
if (msg.type === "command" && commandHandler) {
|
|
172
|
+
commandHandler(msg);
|
|
173
|
+
}
|
|
174
|
+
} catch {} // ignore malformed
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
ws.onclose = () => {
|
|
178
|
+
connected = false;
|
|
179
|
+
reconnecting = false;
|
|
180
|
+
ws = null;
|
|
181
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
182
|
+
if (shouldConnect) scheduleReconnect();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
ws.onerror = () => {
|
|
186
|
+
// onclose will fire after onerror — reconnect handled there
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function scheduleReconnect(): void {
|
|
191
|
+
if (!shouldConnect || reconnectTimer) return;
|
|
192
|
+
const base = BACKOFF_STEPS[Math.min(reconnectAttempt, BACKOFF_STEPS.length - 1)]!;
|
|
193
|
+
// Add ±30% jitter to prevent thundering herd after Cloud deploy
|
|
194
|
+
const jitter = base * (0.7 + Math.random() * 0.6);
|
|
195
|
+
const delay = Math.round(jitter);
|
|
196
|
+
reconnectAttempt++;
|
|
197
|
+
log("WARN", `Cloud WS reconnect in ${delay}ms (attempt #${reconnectAttempt})`);
|
|
198
|
+
reconnectTimer = setTimeout(() => {
|
|
199
|
+
reconnectTimer = null;
|
|
200
|
+
doConnect();
|
|
201
|
+
}, delay);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function log(level: string, msg: string): void {
|
|
205
|
+
const ts = new Date().toISOString();
|
|
206
|
+
const logFile = resolve(process.env.PPM_HOME || resolve(homedir(), ".ppm"), "ppm.log");
|
|
207
|
+
try { appendFileSync(logFile, `[${ts}] [${level}] [cloud-ws] ${msg}\n`); } catch {}
|
|
208
|
+
}
|
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { mkdirSync, existsSync } from "node:fs";
|
|
5
5
|
|
|
6
6
|
const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
|
|
7
|
-
const CURRENT_SCHEMA_VERSION =
|
|
7
|
+
const CURRENT_SCHEMA_VERSION = 9;
|
|
8
8
|
|
|
9
9
|
let db: Database | null = null;
|
|
10
10
|
let dbProfile: string | null = null;
|
|
@@ -240,6 +240,17 @@ function runMigrations(database: Database): void {
|
|
|
240
240
|
PRAGMA user_version = 8;
|
|
241
241
|
`);
|
|
242
242
|
}
|
|
243
|
+
|
|
244
|
+
if (current < 9) {
|
|
245
|
+
database.exec(`
|
|
246
|
+
CREATE TABLE IF NOT EXISTS session_pins (
|
|
247
|
+
session_id TEXT PRIMARY KEY,
|
|
248
|
+
pinned_at TEXT DEFAULT (datetime('now'))
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
PRAGMA user_version = 9;
|
|
252
|
+
`);
|
|
253
|
+
}
|
|
243
254
|
}
|
|
244
255
|
|
|
245
256
|
// ---------------------------------------------------------------------------
|
|
@@ -350,6 +361,25 @@ export function getSessionTitles(sessionIds: string[]): Record<string, string> {
|
|
|
350
361
|
return result;
|
|
351
362
|
}
|
|
352
363
|
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// Session pin helpers
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
export function pinSession(sessionId: string): void {
|
|
369
|
+
getDb().query(
|
|
370
|
+
"INSERT INTO session_pins (session_id, pinned_at) VALUES (?, datetime('now')) ON CONFLICT(session_id) DO UPDATE SET pinned_at = datetime('now')",
|
|
371
|
+
).run(sessionId);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function unpinSession(sessionId: string): void {
|
|
375
|
+
getDb().query("DELETE FROM session_pins WHERE session_id = ?").run(sessionId);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function getPinnedSessionIds(): Set<string> {
|
|
379
|
+
const rows = getDb().query("SELECT session_id FROM session_pins ORDER BY pinned_at DESC").all() as { session_id: string }[];
|
|
380
|
+
return new Set(rows.map((r) => r.session_id));
|
|
381
|
+
}
|
|
382
|
+
|
|
353
383
|
// ---------------------------------------------------------------------------
|
|
354
384
|
// Push subscription helpers
|
|
355
385
|
// ---------------------------------------------------------------------------
|
|
@@ -37,6 +37,24 @@ let tunnelChild: Subprocess | null = null;
|
|
|
37
37
|
let tunnelUrl: string | null = null;
|
|
38
38
|
let shuttingDown = false;
|
|
39
39
|
|
|
40
|
+
type SupervisorState = "running" | "paused" | "upgrading";
|
|
41
|
+
let supervisorState: SupervisorState = "running";
|
|
42
|
+
|
|
43
|
+
let resumeResolve: (() => void) | null = null;
|
|
44
|
+
|
|
45
|
+
function waitForResume(): Promise<void> {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
resumeResolve = resolve;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function triggerResume(): void {
|
|
52
|
+
if (resumeResolve) {
|
|
53
|
+
resumeResolve();
|
|
54
|
+
resumeResolve = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
let serverRestarts = 0;
|
|
41
59
|
let lastServerCrash = 0;
|
|
42
60
|
let tunnelRestarts = 0;
|
|
@@ -129,8 +147,25 @@ export async function spawnServer(
|
|
|
129
147
|
serverRestarts++;
|
|
130
148
|
|
|
131
149
|
if (serverRestarts > MAX_RESTARTS) {
|
|
132
|
-
log("
|
|
133
|
-
|
|
150
|
+
log("WARN", `Server exceeded ${MAX_RESTARTS} restarts, pausing`);
|
|
151
|
+
notifyStateChange("running", "paused", "max_restarts_exceeded");
|
|
152
|
+
supervisorState = "paused";
|
|
153
|
+
updateStatus({
|
|
154
|
+
state: "paused",
|
|
155
|
+
pid: null,
|
|
156
|
+
pausedAt: new Date().toISOString(),
|
|
157
|
+
pauseReason: "max_restarts",
|
|
158
|
+
lastCrashError: `exit ${exitCode}`,
|
|
159
|
+
});
|
|
160
|
+
// Wait for resume signal — supervisor stays alive
|
|
161
|
+
await waitForResume();
|
|
162
|
+
// Resumed — reset and respawn
|
|
163
|
+
notifyStateChange("paused", "running", "user_resume");
|
|
164
|
+
supervisorState = "running";
|
|
165
|
+
serverRestarts = 0;
|
|
166
|
+
updateStatus({ state: "running", pausedAt: null, pauseReason: null });
|
|
167
|
+
log("INFO", "Resuming server after pause");
|
|
168
|
+
if (!shuttingDown) return spawnServer(serverArgs, logFd);
|
|
134
169
|
return;
|
|
135
170
|
}
|
|
136
171
|
|
|
@@ -189,12 +224,7 @@ async function syncUrlToCloud(url: string) {
|
|
|
189
224
|
} catch {}
|
|
190
225
|
}
|
|
191
226
|
|
|
192
|
-
|
|
193
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
194
|
-
heartbeatTimer = setInterval(() => {
|
|
195
|
-
if (tunnelUrl) syncUrlToCloud(tunnelUrl);
|
|
196
|
-
}, 5 * 60 * 1000);
|
|
197
|
-
}
|
|
227
|
+
// HTTP heartbeat removed — WS is the sole heartbeat mechanism (Phase 4)
|
|
198
228
|
|
|
199
229
|
export async function spawnTunnel(port: number): Promise<void> {
|
|
200
230
|
let bin: string;
|
|
@@ -230,9 +260,8 @@ export async function spawnTunnel(port: number): Promise<void> {
|
|
|
230
260
|
updateStatus({ shareUrl: tunnelUrl, tunnelPid: tunnelChild.pid });
|
|
231
261
|
log("INFO", `Tunnel ready: ${tunnelUrl} (PID: ${tunnelChild.pid})`);
|
|
232
262
|
|
|
233
|
-
//
|
|
263
|
+
// One-time sync of tunnel URL to cloud (WS handles periodic heartbeat)
|
|
234
264
|
await syncUrlToCloud(tunnelUrl);
|
|
235
|
-
startCloudHeartbeat(tunnelUrl);
|
|
236
265
|
|
|
237
266
|
const exitCode = await tunnelChild.exited;
|
|
238
267
|
tunnelChild = null;
|
|
@@ -330,6 +359,9 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
|
|
|
330
359
|
try {
|
|
331
360
|
// Prevent spawnServer crash-restart loop from respawning killed children
|
|
332
361
|
shuttingDown = true;
|
|
362
|
+
notifyStateChange(supervisorState, "upgrading", "self_replace");
|
|
363
|
+
supervisorState = "upgrading";
|
|
364
|
+
updateStatus({ state: "upgrading" });
|
|
333
365
|
|
|
334
366
|
// Kill server + tunnel children FIRST to free the port for the new supervisor
|
|
335
367
|
log("INFO", "Stopping server and tunnel before spawning new supervisor");
|
|
@@ -372,20 +404,158 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
|
|
|
372
404
|
log("ERROR", "Self-replace timeout: new supervisor did not start");
|
|
373
405
|
try { child.kill(); } catch {}
|
|
374
406
|
shuttingDown = false;
|
|
407
|
+
notifyStateChange("upgrading", "running", "upgrade_failed");
|
|
408
|
+
supervisorState = "running";
|
|
409
|
+
updateStatus({ state: "running" });
|
|
375
410
|
return { success: false, error: "New supervisor failed to start within 30s" };
|
|
376
411
|
} catch (e) {
|
|
377
412
|
log("ERROR", `Self-replace error: ${e}`);
|
|
378
413
|
shuttingDown = false;
|
|
414
|
+
notifyStateChange("upgrading", "running", "upgrade_failed");
|
|
415
|
+
supervisorState = "running";
|
|
416
|
+
updateStatus({ state: "running" });
|
|
379
417
|
return { success: false, error: (e as Error).message };
|
|
380
418
|
}
|
|
381
419
|
}
|
|
382
420
|
|
|
421
|
+
// ─── Cloud WS integration ─────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
/** Notify Cloud of supervisor state change via WS */
|
|
424
|
+
async function notifyStateChange(from: string, to: string, reason: string) {
|
|
425
|
+
try {
|
|
426
|
+
const { send, isConnected } = await import("./cloud-ws.service.ts");
|
|
427
|
+
if (isConnected()) {
|
|
428
|
+
send({
|
|
429
|
+
type: "state_change",
|
|
430
|
+
from,
|
|
431
|
+
to,
|
|
432
|
+
reason,
|
|
433
|
+
timestamp: new Date().toISOString(),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
} catch {}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Connect supervisor to Cloud via WebSocket (if device is linked) */
|
|
440
|
+
async function connectCloud(opts: { port: number }, serverArgs: string[], logFd: number) {
|
|
441
|
+
try {
|
|
442
|
+
const { getCloudDevice } = await import("./cloud.service.ts");
|
|
443
|
+
const device = getCloudDevice();
|
|
444
|
+
if (!device) return; // not linked to cloud
|
|
445
|
+
|
|
446
|
+
const { connect, onCommand } = await import("./cloud-ws.service.ts");
|
|
447
|
+
const { VERSION } = await import("../version.ts");
|
|
448
|
+
const startTime = Date.now();
|
|
449
|
+
|
|
450
|
+
connect({
|
|
451
|
+
cloudUrl: device.cloud_url,
|
|
452
|
+
deviceId: device.device_id,
|
|
453
|
+
secretKey: device.secret_key,
|
|
454
|
+
heartbeatFn: () => ({
|
|
455
|
+
type: "heartbeat" as const,
|
|
456
|
+
tunnelUrl,
|
|
457
|
+
state: supervisorState,
|
|
458
|
+
appVersion: VERSION,
|
|
459
|
+
serverPid: serverChild?.pid ?? null,
|
|
460
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
461
|
+
timestamp: new Date().toISOString(),
|
|
462
|
+
}),
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// Handle commands from Cloud
|
|
466
|
+
onCommand(async (cmd) => {
|
|
467
|
+
const { send } = await import("./cloud-ws.service.ts");
|
|
468
|
+
const sendResult = (success: boolean, error?: string, data?: Record<string, unknown>) => {
|
|
469
|
+
send({
|
|
470
|
+
type: "command_result",
|
|
471
|
+
id: cmd.id,
|
|
472
|
+
success,
|
|
473
|
+
error,
|
|
474
|
+
data,
|
|
475
|
+
timestamp: new Date().toISOString(),
|
|
476
|
+
});
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
log("INFO", `Cloud command received: ${cmd.action}`);
|
|
480
|
+
|
|
481
|
+
switch (cmd.action) {
|
|
482
|
+
case "restart":
|
|
483
|
+
if (serverChild) {
|
|
484
|
+
serverRestartRequested = true;
|
|
485
|
+
try { serverChild.kill(); } catch {}
|
|
486
|
+
sendResult(true);
|
|
487
|
+
} else if (supervisorState === "paused") {
|
|
488
|
+
triggerResume();
|
|
489
|
+
sendResult(true);
|
|
490
|
+
} else {
|
|
491
|
+
sendResult(false, "No server child to restart");
|
|
492
|
+
}
|
|
493
|
+
break;
|
|
494
|
+
|
|
495
|
+
case "resume":
|
|
496
|
+
if (supervisorState === "paused") {
|
|
497
|
+
triggerResume();
|
|
498
|
+
sendResult(true);
|
|
499
|
+
} else {
|
|
500
|
+
sendResult(false, "Not in paused state");
|
|
501
|
+
}
|
|
502
|
+
break;
|
|
503
|
+
|
|
504
|
+
case "stop":
|
|
505
|
+
sendResult(true);
|
|
506
|
+
// Delay exit to allow WS buffer to flush
|
|
507
|
+
setTimeout(() => {
|
|
508
|
+
shutdown();
|
|
509
|
+
process.exit(0);
|
|
510
|
+
}, 500);
|
|
511
|
+
break;
|
|
512
|
+
|
|
513
|
+
case "upgrade":
|
|
514
|
+
// Send result BEFORE selfReplace (which exits on success)
|
|
515
|
+
sendResult(true, undefined, { status: "upgrading" });
|
|
516
|
+
await new Promise(r => setTimeout(r, 300));
|
|
517
|
+
const result = await selfReplace();
|
|
518
|
+
// Only reaches here on failure — selfReplace exits on success
|
|
519
|
+
if (!result.success) {
|
|
520
|
+
sendResult(false, result.error);
|
|
521
|
+
if (!serverChild && !shuttingDown) {
|
|
522
|
+
spawnServer(serverArgs, logFd);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
break;
|
|
526
|
+
|
|
527
|
+
case "status":
|
|
528
|
+
sendResult(true, undefined, {
|
|
529
|
+
state: supervisorState,
|
|
530
|
+
serverPid: serverChild?.pid ?? null,
|
|
531
|
+
tunnelUrl,
|
|
532
|
+
serverRestarts,
|
|
533
|
+
});
|
|
534
|
+
break;
|
|
535
|
+
|
|
536
|
+
default:
|
|
537
|
+
sendResult(false, `Unknown action: ${cmd.action}`);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
} catch (e) {
|
|
541
|
+
log("WARN", `Cloud WS setup failed: ${e}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
383
545
|
// ─── Shutdown ──────────────────────────────────────────────────────────
|
|
384
546
|
export function shutdown() {
|
|
385
547
|
if (shuttingDown) return;
|
|
386
548
|
shuttingDown = true;
|
|
387
549
|
log("INFO", "Supervisor shutting down");
|
|
388
550
|
|
|
551
|
+
// Unblock if paused
|
|
552
|
+
triggerResume();
|
|
553
|
+
|
|
554
|
+
// Disconnect Cloud WS
|
|
555
|
+
import("./cloud-ws.service.ts")
|
|
556
|
+
.then(({ disconnect }) => disconnect())
|
|
557
|
+
.catch(() => {});
|
|
558
|
+
|
|
389
559
|
if (healthTimer) clearInterval(healthTimer);
|
|
390
560
|
if (tunnelProbeTimer) clearInterval(tunnelProbeTimer);
|
|
391
561
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
@@ -414,7 +584,10 @@ export async function runSupervisor(opts: {
|
|
|
414
584
|
|
|
415
585
|
// Write supervisor PID + clear stale availableVersion from previous run
|
|
416
586
|
writeFileSync(PID_FILE, String(process.pid));
|
|
417
|
-
updateStatus({
|
|
587
|
+
updateStatus({
|
|
588
|
+
supervisorPid: process.pid, port: opts.port, host: opts.host, availableVersion: null,
|
|
589
|
+
state: "running", pausedAt: null, pauseReason: null, lastCrashError: null,
|
|
590
|
+
});
|
|
418
591
|
|
|
419
592
|
// Build __serve__ args
|
|
420
593
|
const serverArgs = [
|
|
@@ -428,8 +601,13 @@ export async function runSupervisor(opts: {
|
|
|
428
601
|
process.on("SIGTERM", () => { shutdown(); process.exit(0); });
|
|
429
602
|
process.on("SIGINT", () => { shutdown(); process.exit(0); });
|
|
430
603
|
|
|
431
|
-
// SIGUSR2 = graceful server restart (tunnel stays alive)
|
|
604
|
+
// SIGUSR2 = graceful server restart (tunnel stays alive) or resume from paused
|
|
432
605
|
process.on("SIGUSR2", () => {
|
|
606
|
+
if (supervisorState === "paused") {
|
|
607
|
+
log("INFO", "SIGUSR2 received while paused, resuming server");
|
|
608
|
+
triggerResume();
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
433
611
|
log("INFO", "SIGUSR2 received, restarting server only");
|
|
434
612
|
if (serverChild) {
|
|
435
613
|
serverRestartRequested = true; // flag so spawnServer skips backoff
|
|
@@ -458,6 +636,9 @@ export async function runSupervisor(opts: {
|
|
|
458
636
|
upgradeCheckTimer = setInterval(checkAvailableVersion, UPGRADE_CHECK_INTERVAL_MS);
|
|
459
637
|
}, UPGRADE_SKIP_INITIAL_MS);
|
|
460
638
|
|
|
639
|
+
// Connect to Cloud via WebSocket (if device is linked)
|
|
640
|
+
connectCloud(opts, serverArgs, logFd);
|
|
641
|
+
|
|
461
642
|
// Spawn server + tunnel in parallel
|
|
462
643
|
const promises: Promise<void>[] = [spawnServer(serverArgs, logFd)];
|
|
463
644
|
|
package/src/types/chat.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
-
import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck } from "lucide-react";
|
|
2
|
+
import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff } from "lucide-react";
|
|
3
3
|
import { Activity } from "lucide-react";
|
|
4
4
|
import { api, projectUrl } from "@/lib/api-client";
|
|
5
5
|
import { useTabStore } from "@/stores/tab-store";
|
|
@@ -149,6 +149,27 @@ export function ChatHistoryBar({
|
|
|
149
149
|
|
|
150
150
|
const cancelEditing = useCallback(() => setEditingId(null), []);
|
|
151
151
|
|
|
152
|
+
const togglePin = useCallback(async (e: React.MouseEvent, session: SessionInfo) => {
|
|
153
|
+
e.stopPropagation();
|
|
154
|
+
if (!projectName) return;
|
|
155
|
+
const url = `${projectUrl(projectName)}/chat/sessions/${session.id}/pin`;
|
|
156
|
+
try {
|
|
157
|
+
if (session.pinned) {
|
|
158
|
+
await api.del(url);
|
|
159
|
+
} else {
|
|
160
|
+
await api.put(url);
|
|
161
|
+
}
|
|
162
|
+
setSessions((prev) => {
|
|
163
|
+
const updated = prev.map((s) => s.id === session.id ? { ...s, pinned: !s.pinned } : s);
|
|
164
|
+
return updated.sort((a, b) => {
|
|
165
|
+
if (a.pinned && !b.pinned) return -1;
|
|
166
|
+
if (!a.pinned && b.pinned) return 1;
|
|
167
|
+
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
} catch { /* silent */ }
|
|
171
|
+
}, [projectName]);
|
|
172
|
+
|
|
152
173
|
// Filter sessions by search query
|
|
153
174
|
const filteredSessions = searchQuery.trim()
|
|
154
175
|
? sessions.filter((s) => (s.title || "").toLowerCase().includes(searchQuery.toLowerCase()))
|
|
@@ -310,9 +331,20 @@ export function ChatHistoryBar({
|
|
|
310
331
|
>
|
|
311
332
|
{session.title || "Untitled"}
|
|
312
333
|
</button>
|
|
334
|
+
<button
|
|
335
|
+
onClick={(e) => togglePin(e, session)}
|
|
336
|
+
className={`p-0.5 rounded transition-all ${
|
|
337
|
+
session.pinned
|
|
338
|
+
? "text-primary hover:text-primary/70"
|
|
339
|
+
: "text-text-subtle hover:text-text-secondary md:opacity-0 md:group-hover:opacity-100"
|
|
340
|
+
}`}
|
|
341
|
+
title={session.pinned ? "Unpin session" : "Pin session"}
|
|
342
|
+
>
|
|
343
|
+
{session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
|
|
344
|
+
</button>
|
|
313
345
|
<button
|
|
314
346
|
onClick={(e) => startEditing(session, e)}
|
|
315
|
-
className="p-0.5 rounded text-text-subtle hover:text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity"
|
|
347
|
+
className="p-0.5 rounded text-text-subtle hover:text-text-secondary md:opacity-0 md:group-hover:opacity-100 transition-opacity"
|
|
316
348
|
title="Rename session"
|
|
317
349
|
>
|
|
318
350
|
<Pencil className="size-3" />
|
|
@@ -320,7 +352,7 @@ export function ChatHistoryBar({
|
|
|
320
352
|
</>
|
|
321
353
|
)}
|
|
322
354
|
{editingId !== session.id && session.updatedAt && (
|
|
323
|
-
<span className="text-[10px] text-text-subtle shrink-0">{formatDate(session.updatedAt)}</span>
|
|
355
|
+
<span className="text-[10px] text-text-subtle shrink-0 w-10 text-right">{formatDate(session.updatedAt)}</span>
|
|
324
356
|
)}
|
|
325
357
|
</div>
|
|
326
358
|
))
|